diff --git a/biome.json b/biome.json index aa33de87a..ae7b60129 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "linter": { "enabled": true, "rules": { diff --git a/bun.lock b/bun.lock index 1b96e8fd8..c86d065b3 100644 --- a/bun.lock +++ b/bun.lock @@ -89,9 +89,10 @@ }, "devDependencies": { "@types/diff": "^7.0.2", + "@types/ms": "^2.1.0", "@types/ndjson": "^2.0.4", "@types/node": "^24.3.0", - "vitest": "^3.2.4", + "ms": "^2.1.3", }, }, "packages/git-tool": { @@ -432,49 +433,55 @@ "@preact/signals-core": ["@preact/signals-core@1.12.1", "", {}, "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.46", "", {}, "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ=="], @@ -540,6 +547,8 @@ "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/ndjson": ["@types/ndjson@2.0.4", "", { "dependencies": { "@types/node": "*", "@types/through": "*" } }, "sha512-ajAl7AjhFstF6waORYNSS49GL5iBKisqJlgvXuprXFKCX9fto4ordlNU3+XMgkMddgeR0WoQQBmKUk0v0dJ4pw=="], "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], @@ -554,21 +563,21 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260104.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260104.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260104.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260104.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uUf3nybwgHpDpzPFjreMb6aDnyp1vcVdWqp7Ac4bmiOAtE0TlmvoSI7KTJeTZ8xozFVyHyCoFch5bNyDQr3uHQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260105.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260105.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260105.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260105.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-PjmhqnN/jRDLxG/5EuCe8AlW1QUEOjcDJQsxarQmMbrdW5DbSDnYUvWbYbJescXefeK0v3FUQmp7HAOCFQ7I/w=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260104.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tVPVpozQmSVTpn97Uu+ZDdC5G9YkHt6YPgv/wtzDVLc8CRCpCwIZZF8MvWMar8f4WiA+3uJIMeX+RAsSQknu5w=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260105.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-d+CJrdiElzHuckgsXLHlBRLbHsgzWqQuSVOZ/raF6cvBKjylnphNPx+CdtOpZrBCic0M30Q/UfTV6StMDhjIrQ=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260104.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-6fdywq9zx2QJtDpZKt59e+y8P/WDd10KsX6MReDpaKXTtGpqkVq9+Zb7LbNqRlLJU4gpwURepCTqX/AsZjzUwg=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260105.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-ntgJZDSNh7tqw2bfBuEEqhwUbuALLTLY4E/pLdCC8vaL/2QrNcTuDZX23hcLa7pQL6ML2OVvEH1MC7A1BUO/WA=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "arm" }, "sha512-mi/NiyKyH/CpNrL3FNCL7Vp9Q/c7MW+7La6xNgpG29M7SK4U9LnBnJLSHS0ahpnt2QsZBlCY6NaRZpbKT2pYNw=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Pv8aEc9OKG++9dPzyWwZRXt1WEHH7saAYpGrYWKnR0mMDt8yRRPX2g9ReCSFLJ/9ji3nvBg3n/wUXDXOdMQDDg=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-M7vUDJGXDUfIKO7X+DlBIoKaE3uA6dKx8wVky39FHcvjiTJIR+tphPU3uLY7xLpuj3y9PtGQQ2WoGY2wcft4Ug=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uBj+8EPCZ8bFSosovwmgF16r4NXHgq0Wc3Ddg48KDqUl9njsVjWbg6jv3H5OXm7nnLKgRzmq9B30cHuWhlKsMg=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260104.1", "", { "os": "linux", "cpu": "x64" }, "sha512-58eQFmQLCFHmPxyaLT7p3lJNHjcxqLwxO5IFAujrMp+VPHCUuL9QfxcKWnCJlkjgoKd8c95pT0ZNzBhsq00Hyg=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260105.1", "", { "os": "linux", "cpu": "x64" }, "sha512-vy1IpYseSpIP6X0TKnejwKH75LO06VfI7DRV9ShqgHC6Ybd7AdrTrXlna13sFF6FuD8vd3ZefesbS4n4d0Ywzw=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260104.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-g5pE6oveg7lDsBP8nYnjhikMzoZbMWEVYG1JkVZcNUG9equw1Fg4cZdBa/HbZENj1Afy9xrzrRywpvhAYF0lDw=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260105.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EFSpj3zgVsYTztgeuxhOlOpx0hr2vTVxPyccEHfiseMcAjkAsbdoSeYSLZFZyW2JXqJtq6N3YWsGGKzoyP0f+Q=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260104.1", "", { "os": "win32", "cpu": "x64" }, "sha512-kdufkNpH5DNIeJ5ZHsfcxTC30IfTkvW+ITc0IHHv4plgKiTJ71i/BZDavkTNmpd1zODe4P8PVGrRrGYpdVF06A=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260105.1", "", { "os": "win32", "cpu": "x64" }, "sha512-UK0+xTxu6UZA8isOThWT9LOAl6LB1WyYFLC0ijno4SWwQCIbnJi3wjnAkBxRyM8h46DUaDYpXfHVkpYwhdExhg=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -1136,7 +1145,7 @@ "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], - "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], + "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md new file mode 100644 index 000000000..4a76fa43b --- /dev/null +++ b/docs/porting-from-pi-mono.md @@ -0,0 +1,215 @@ +# Porting From pi-mono: A Practical Merge Guide + +This guide is a repeatable checklist for porting changes from pi-mono into this repo. +Use it for any merge: single file, feature branch, or full release sync. + +## 0) Define the scope + +- Identify the upstream reference (commit, tag, or PR). +- List the packages or folders you plan to touch. +- Decide which features are in-scope and which are intentionally skipped. + +## 1) Bring code over safely + +- Prefer a clean, focused diff rather than a wholesale copy. +- Avoid copying built artifacts or generated files. +- If upstream added new files, add them explicitly and review contents. + +## 2) Remove `.js` from imports + +We use a bundler and strip `.js` from TypeScript imports. + +- Remove `.js` extensions from all internal imports. +- Keep real file extensions only when required by tooling (e.g., `.json`, `.css`). +- Example: + - `import { x } from "./foo.js";` -> `import { x } from "./foo";` + +## 3) Replace import scopes + +Upstream uses different package scopes. Replace them consistently. + +- Replace old scopes with the local scope used here. +- Examples (adjust to match the actual packages you are porting): + - `@mariozechner/pi-coding-agent` -> `@oh-my-pi/pi-coding-agent` + - `@mariozechner/pi-ai` -> `@oh-my-pi/pi-ai` + - `@badlogic/pi-tui` -> `@oh-my-pi/pi-tui` + +## 4) Use Bun APIs where they improve on Node + +We run on Bun. Replace Node APIs only when Bun provides a better alternative. + +**DO replace:** +- Process spawning: `child_process.spawn` → `Bun.spawn` / `Bun.spawnSync` +- File I/O: `fs.readFileSync` → `Bun.file().text()` / `Bun.write()` +- HTTP clients: `node-fetch`, `axios` → native `fetch` +- Crypto hashing: `node:crypto` → Web Crypto or `Bun.hash` +- SQLite: `better-sqlite3` → `bun:sqlite` +- Env loading: `dotenv` → Bun loads `.env` automatically + +**DO NOT replace (these work fine in Bun):** +- `os.homedir()` — do NOT replace with `process.env.HOME`, `Bun.env.HOME`, or literal `"~"` +- `os.tmpdir()` — do NOT replace with `Bun.env.TMPDIR || "/tmp"` or hardcoded paths +- `fs.mkdtempSync()` — do NOT replace with manual path construction +- `path.join()`, `path.resolve()`, etc. — these are fine + +**Import style:** Use `node:` prefix for Node builtins (`import { homedir } from "node:os"`). + +**Wrong:** +```typescript +// BROKEN: env vars may be undefined, "~" is not expanded +const home = process.env.HOME || Bun.env.HOME || "~"; +const tmp = Bun.env.TMPDIR || "/tmp"; +``` + +**Correct:** +```typescript +import { homedir, tmpdir } from "node:os"; +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; + +const configDir = join(homedir(), ".config", "myapp"); +const tempDir = mkdtempSync(join(tmpdir(), "myapp-")); +``` + +## 5) Prefer Bun embeds (no copying) + +Do not copy runtime assets or vendor files at build time. + +- If upstream copies assets into a dist folder, replace with Bun-friendly embeds. +- Use `import.meta.dir` + `Bun.file` to load adjacent resources. +- Keep assets in-repo and let the bundler include them. +- Eliminate copy scripts unless the user explicitly requests them. +- If upstream reads a bundled fallback file at runtime, replace filesystem reads with a Bun text embed import. + - Example (Codex instructions fallback): + - `const FALLBACK_PROMPT_PATH = join(import.meta.dir, "codex-instructions.md");` -> removed + - `import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" };` + - Use `return FALLBACK_INSTRUCTIONS;` instead of `readFileSync(FALLBACK_PROMPT_PATH, "utf8")` + +## 6) Port `package.json` carefully + +Treat `package.json` as a contract. Merge intentionally. + +- Keep existing `name`, `version`, `type`, `exports`, and `bin` unless the port requires changes. +- Replace npm/node scripts with Bun equivalents (e.g., `bun run`, `bun test`). +- Ensure dependencies use the correct scope. +- Do not downgrade dependencies to fix type errors; upgrade instead. +- Validate workspace package links and `peerDependencies`. + +## 7) Align code style and tooling + +- Keep existing formatting conventions. +- Do not introduce `any` unless required. +- Avoid dynamic imports and inline type imports. +- Prefer existing helpers and utilities over new ad-hoc code. +- Preserve Bun-first infrastructure changes already made in this repo: + - Runtime is Bun (no Node entry points). + - Package manager is Bun (no npm lockfiles). + - Heavy Node APIs (`child_process`, `readline`) are replaced with Bun equivalents. + - Lightweight Node APIs (`os.homedir`, `os.tmpdir`, `fs.mkdtempSync`, `path.*`) are kept. + - CLI shebangs use `bun` (not `node`, not `tsx`). + - Packages use source files directly (no TypeScript build step). + - CI workflows run Bun for install/check/test. + +## 8) Remove old compatibility layers + +Unless requested, remove upstream compatibility shims. + +- Delete old APIs that were replaced. +- Update all call sites to the new API directly. +- Do not keep `*_v2` or parallel versions. + +## 9) Update docs and references + +- Replace pi-mono repo links where appropriate. +- Update examples to use Bun and correct package scopes. +- Ensure README instructions still match the current repo behavior. + +## 10) Validate the port + +Run the standard checks after changes: + +- `bun run check` + +If the repo already has failing checks unrelated to your changes, call that out. +Tests use Bun's runner (not Vitest), but only run `bun test` when explicitly requested. + +## 11) Protect improved features (regression trap list) + +If you already improved behavior locally, treat those as **non‑negotiable**. Before porting, write down +the improvements and add explicit checks so they don’t get lost in the merge. + +- **Freeze the expected behavior**: add a short “before/after” note for each improvement (inputs, outputs, + defaults, edge cases). This prevents silent rollback. +- **Map old → new APIs**: if upstream renamed concepts (hooks → extensions, custom tools → tools, etc.), + ensure every old entry point still wires through. One missed flag or export equals lost functionality. +- **Verify exports**: check `package.json` `exports`, public types, and barrel files. Upstream ports often + forget to re-export local additions. +- **Cover non‑happy paths**: if you fixed error handling, timeouts, or fallback logic, add a test or at + least a manual checklist that exercises those paths. +- **Check defaults and config merge order**: improvements often live in defaults. Confirm new defaults + didn’t revert (e.g., new config precedence, disabled features, tool lists). +- **Audit env/shell behavior**: if you fixed execution or sandboxing, verify the new path still uses your + sanitized env and does not reintroduce alias/function overrides. +- **Re-run targeted samples**: keep a minimal set of “known good” examples and run them after the port + (CLI flags, extension registration, tool execution). + +## 11) Detect and handle reworked code + +Before porting a file, check if upstream significantly refactored it: + +```bash +# Compare the file you're about to port against what you have locally +git diff HEAD upstream/main -- path/to/file.ts +``` + +If the diff shows the file was **reworked** (not just patched): +- New abstractions, renamed concepts, merged modules, changed data flow + +Then you must **read the new implementation thoroughly** before porting. Blind merging of reworked code loses functionality because: + +1. **Defaults change silently** - A new variable `defaultFoo = [a, b]` may replace an old `getAllFoo()` that returned `[a, b, c, d, e]`. + +2. **API options get dropped** - When systems merge (e.g., `hooks` + `customTools` → `extensions`), old options may not wire through to the new implementation. + +3. **Code paths go stale** - A renamed concept (e.g., `hookMessage` → `custom`) needs updates in every switch statement, type guard, and handler—not just the definition. + +4. **Context/capabilities shrink** - Old APIs may have exposed `{ logger, typebox, pi }` that new APIs forgot to include. + +### Semantic porting process + +When upstream reworked a module: + +1. **Read the old implementation** - Understand what it did, what options it accepted, what it exposed. + +2. **Read the new implementation** - Understand the new abstractions and how they map to old behavior. + +3. **Verify feature parity** - For each capability in the old code, confirm the new code preserves it or explicitly removes it. + +4. **Grep for stragglers** - Search for old names/concepts that may have been missed in switch statements, handlers, UI components. + +5. **Test the boundaries** - CLI flags, SDK options, event handlers, default values—these are where regressions hide. + +### Quick checks + +```bash +# Find all uses of an old concept that may need updating +rg "oldConceptName" --type ts + +# Compare default values between versions +git show upstream/main:path/to/file.ts | rg "default|DEFAULT" + +# Check if all enum/union values have handlers +rg "case \"" path/to/file.ts +``` + +## 12) Quick audit checklist + +Use this as a final pass before you finish: + +- [ ] No `.js` import extensions in TS files +- [ ] No Node-only APIs in new/ported code +- [ ] All package scopes updated +- [ ] `package.json` scripts use Bun +- [ ] Assets load via Bun embed patterns (no copy scripts) +- [ ] Tests or checks run (or explicitly noted as blocked) +- [ ] No functionality regressions (see section 11) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index a8e74ff49..09fbb8021 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Replaced `queueMessage`/`queueMode` with steering + follow-up queues: use `steer`, `setSteeringMode`, and `getSteeringMode` for mid-run interruptions, and `followUp`, `setFollowUpMode`, and `getFollowUpMode` for post-turn messages +- Agent loop callbacks now use `getSteeringMessages` and `getFollowUpMessages` instead of `getQueuedMessages` + +### Added + +- Added follow-up message queue support so new user messages can continue a run after the agent would otherwise stop +- Added `RenderResultOptions.spinnerFrame` for animated tool-result rendering + +### Changed + +- `prompt()` and `continue()` now throw when the agent is already streaming; use steering or follow-up queues instead ## [3.15.1] - 2026-01-05 @@ -104,4 +117,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon - `Agent` constructor now has all options optional (empty options use defaults). -- `queueMessage()` is now synchronous (no longer returns a Promise). \ No newline at end of file +- `queueMessage()` is now synchronous (no longer returns a Promise). diff --git a/packages/agent/README.md b/packages/agent/README.md index 2fd4978ac..481149b14 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -224,26 +224,31 @@ const unsubscribe = agent.subscribe((event) => { unsubscribe(); ``` -## Message Queue +## Steering & Follow-up -Queue messages to inject during tool execution (for user interruptions): +Queue messages to inject during tool execution (steering) or after the agent would otherwise stop (follow-up): ```typescript -agent.setQueueMode("one-at-a-time"); +agent.setSteeringMode("one-at-a-time"); +agent.setInterruptMode("immediate"); // While agent is running tools -agent.queueMessage({ +agent.steer({ role: "user", content: "Stop! Do this instead.", timestamp: Date.now(), }); + +// Queue a follow-up to run after the current turn completes +agent.followUp({ + role: "user", + content: "After that, summarize the changes.", + timestamp: Date.now(), +}); ``` -When queued messages are detected after a tool completes: - -1. Remaining tools are skipped with error results -2. Queued message is injected -3. LLM responds to the interruption +Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer +steering until the current turn completes. ## Custom Message Types diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index e89ddb143..b75d0fc3f 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -109,73 +109,90 @@ async function runLoop( stream: EventStream, streamFn?: StreamFn, ): Promise { - let hasMoreToolCalls = true; let firstTurn = true; - let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || []; - let queuedAfterTools: AgentMessage[] | null = null; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; - while (hasMoreToolCalls || queuedMessages.length > 0) { - if (!firstTurn) { - stream.push({ type: "turn_start" }); - } else { - firstTurn = false; - } + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + let steeringAfterTools: AgentMessage[] | null = null; - // Process queued messages (inject before next assistant response) - if (queuedMessages.length > 0) { - for (const message of queuedMessages) { - stream.push({ type: "message_start", message }); - stream.push({ type: "message_end", message }); - currentContext.messages.push(message); - newMessages.push(message); + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + stream.push({ type: "turn_start" }); + } else { + firstTurn = false; } - queuedMessages = []; - } - // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); - newMessages.push(message); + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + stream.push({ type: "message_start", message }); + stream.push({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } - if (message.stopReason === "error" || message.stopReason === "aborted") { - stream.push({ type: "turn_end", message, toolResults: [] }); - stream.push({ type: "agent_end", messages: newMessages }); - stream.end(newMessages); - return; - } + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); + newMessages.push(message); - // Check for tool calls - const toolCalls = message.content.filter((c) => c.type === "toolCall"); - hasMoreToolCalls = toolCalls.length > 0; + if (message.stopReason === "error" || message.stopReason === "aborted") { + stream.push({ type: "turn_end", message, toolResults: [] }); + stream.push({ type: "agent_end", messages: newMessages }); + stream.end(newMessages); + return; + } - const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { - const toolExecution = await executeToolCalls( - currentContext.tools, - message, - signal, - stream, - config.getQueuedMessages, - config.getToolContext, - config.interruptMode, - ); - toolResults.push(...toolExecution.toolResults); - queuedAfterTools = toolExecution.queuedMessages ?? null; + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + hasMoreToolCalls = toolCalls.length > 0; - for (const result of toolResults) { - currentContext.messages.push(result); - newMessages.push(result); + const toolResults: ToolResultMessage[] = []; + if (hasMoreToolCalls) { + const toolExecution = await executeToolCalls( + currentContext.tools, + message, + signal, + stream, + config.getSteeringMessages, + config.getToolContext, + config.interruptMode, + ); + toolResults.push(...toolExecution.toolResults); + steeringAfterTools = toolExecution.steeringMessages ?? null; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + stream.push({ type: "turn_end", message, toolResults }); + + // Get steering messages after turn completes + if (steeringAfterTools && steeringAfterTools.length > 0) { + pendingMessages = steeringAfterTools; + steeringAfterTools = null; + } else { + pendingMessages = (await config.getSteeringMessages?.()) || []; } } - stream.push({ type: "turn_end", message, toolResults }); - - // Get queued messages after turn completes - if (queuedAfterTools && queuedAfterTools.length > 0) { - queuedMessages = queuedAfterTools; - queuedAfterTools = null; - } else { - queuedMessages = (await config.getQueuedMessages?.()) || []; + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; } + + // No more messages, exit + break; } stream.push({ type: "agent_end", messages: newMessages }); @@ -225,9 +242,35 @@ async function streamAssistantResponse( let addedPartial = false; for await (const event of response) { - // Check abort early - allows TTSR and other abort sources to break immediately + // Check for abort signal before processing each event if (signal?.aborted) { - break; + const abortedMessage: AssistantMessage = partialMessage + ? { ...partialMessage, stopReason: "aborted" } + : { + role: "assistant", + content: [], + api: config.model.api, + provider: config.model.provider, + model: config.model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "aborted", + timestamp: Date.now(), + }; + if (addedPartial) { + context.messages[context.messages.length - 1] = abortedMessage; + } else { + context.messages.push(abortedMessage); + stream.push({ type: "message_start", message: { ...abortedMessage } }); + } + stream.push({ type: "message_end", message: abortedMessage }); + return abortedMessage; } switch (event.type) { @@ -273,22 +316,6 @@ async function streamAssistantResponse( return finalMessage; } } - - // Check abort after processing - allows handlers to abort mid-stream - if (signal?.aborted) { - break; - } - } - - // If we broke out due to abort, return an aborted message - if (signal?.aborted && partialMessage) { - const abortedMessage: AssistantMessage = { - ...partialMessage, - stopReason: "aborted", - }; - context.messages[context.messages.length - 1] = abortedMessage; - stream.push({ type: "message_end", message: abortedMessage }); - return abortedMessage; } return await response.result(); @@ -302,13 +329,14 @@ async function executeToolCalls( assistantMessage: AssistantMessage, signal: AbortSignal | undefined, stream: EventStream, - getQueuedMessages?: AgentLoopConfig["getQueuedMessages"], + getSteeringMessages?: AgentLoopConfig["getSteeringMessages"], getToolContext?: AgentLoopConfig["getToolContext"], - interruptMode?: AgentLoopConfig["interruptMode"], -): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> { + interruptMode: AgentLoopConfig["interruptMode"] = "immediate", +): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); const results: ToolResultMessage[] = []; - let queuedMessages: AgentMessage[] | undefined; + let steeringMessages: AgentMessage[] | undefined; + const shouldInterruptImmediately = interruptMode !== "wait"; for (let index = 0; index < toolCalls.length; index++) { const toolCall = toolCalls[index]; @@ -322,17 +350,14 @@ async function executeToolCalls( }); let result: AgentToolResult; + let isError = false; - const details: { toolCallId: string; toolName: string; isError?: boolean } = { - toolCallId: toolCall.id, - toolName: toolCall.name, - }; try { if (!tool) throw new Error(`Tool ${toolCall.name} not found`); const validatedArgs = validateToolArguments(tool, toolCall); - const toolContext = getToolContext?.(); + const toolContext = getToolContext ? getToolContext() : undefined; result = await tool.execute( toolCall.id, validatedArgs, @@ -353,33 +378,36 @@ async function executeToolCalls( content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], details: {}, }; - details.isError = true; + isError = true; } stream.push({ type: "tool_execution_end", + toolCallId: toolCall.id, + toolName: toolCall.name, result, - ...details, + isError, }); const toolResultMessage: ToolResultMessage = { role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, content: result.content, details: result.details, + isError, timestamp: Date.now(), - ...details, }; results.push(toolResultMessage); stream.push({ type: "message_start", message: toolResultMessage }); stream.push({ type: "message_end", message: toolResultMessage }); - // Check for queued messages - skip remaining tools if user interrupted - // Only interrupt mid-execution if interruptMode is "immediate" (default) - if (interruptMode !== "wait" && getQueuedMessages) { - const queued = await getQueuedMessages(); - if (queued.length > 0) { - queuedMessages = queued; + // Check for steering messages - skip remaining tools if user interrupted + if (shouldInterruptImmediately && getSteeringMessages) { + const steering = await getSteeringMessages(); + if (steering.length > 0) { + steeringMessages = steering; const remainingCalls = toolCalls.slice(index + 1); for (const skipped of remainingCalls) { results.push(skipToolCall(skipped, stream)); @@ -389,7 +417,7 @@ async function executeToolCalls( } } - return { toolResults: results, queuedMessages }; + return { toolResults: results, steeringMessages }; } function skipToolCall( diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 0bbeaddd5..764fa7e1f 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -48,13 +48,19 @@ export interface AgentOptions { transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; /** - * Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn + * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn */ - queueMode?: "all" | "one-at-a-time"; + steeringMode?: "all" | "one-at-a-time"; /** - * Interrupt mode: "immediate" = check queue after each tool (interrupt remaining), - * "wait" = only process queue after turn completes + * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn + */ + followUpMode?: "all" | "one-at-a-time"; + + /** + * When to interrupt tool execution for steering messages. + * - "immediate": check after each tool call (default) + * - "wait": defer steering until the current turn completes */ interruptMode?: "immediate" | "wait"; @@ -71,6 +77,7 @@ export interface AgentOptions { /** * Provides tool execution context, resolved per tool call. + * Use for late-bound UI or session state access. */ getToolContext?: () => AgentToolContext | undefined; } @@ -92,8 +99,10 @@ export class Agent { private abortController?: AbortController; private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - private messageQueue: AgentMessage[] = []; - private queueMode: "all" | "one-at-a-time"; + private steeringQueue: AgentMessage[] = []; + private followUpQueue: AgentMessage[] = []; + private steeringMode: "all" | "one-at-a-time"; + private followUpMode: "all" | "one-at-a-time"; private interruptMode: "immediate" | "wait"; public streamFn: StreamFn; public getApiKey?: (provider: string) => Promise | string | undefined; @@ -105,7 +114,8 @@ export class Agent { this._state = { ...this._state, ...opts.initialState }; this.convertToLlm = opts.convertToLlm || defaultConvertToLlm; this.transformContext = opts.transformContext; - this.queueMode = opts.queueMode || "one-at-a-time"; + this.steeringMode = opts.steeringMode || "one-at-a-time"; + this.followUpMode = opts.followUpMode || "one-at-a-time"; this.interruptMode = opts.interruptMode || "immediate"; this.streamFn = opts.streamFn || streamSimple; this.getApiKey = opts.getApiKey; @@ -134,12 +144,20 @@ export class Agent { this._state.thinkingLevel = l; } - setQueueMode(mode: "all" | "one-at-a-time") { - this.queueMode = mode; + setSteeringMode(mode: "all" | "one-at-a-time") { + this.steeringMode = mode; } - getQueueMode(): "all" | "one-at-a-time" { - return this.queueMode; + getSteeringMode(): "all" | "one-at-a-time" { + return this.steeringMode; + } + + setFollowUpMode(mode: "all" | "one-at-a-time") { + this.followUpMode = mode; + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.followUpMode; } setInterruptMode(mode: "immediate" | "wait") { @@ -162,26 +180,51 @@ export class Agent { this._state.messages = [...this._state.messages, m]; } - queueMessage(m: AgentMessage) { - this.messageQueue.push(m); + popMessage(): AgentMessage | undefined { + const messages = this._state.messages.slice(0, -1); + const removed = this._state.messages.at(-1); + this._state.messages = messages; + + if (removed && this._state.streamMessage === removed) { + this._state.streamMessage = null; + } + + return removed; } - clearMessageQueue() { - this.messageQueue = []; + /** + * Queue a steering message to interrupt the agent mid-run. + * Delivered after current tool execution, skips remaining tools. + */ + steer(m: AgentMessage) { + this.steeringQueue.push(m); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + * Delivered only when agent has no more tool calls or steering messages. + */ + followUp(m: AgentMessage) { + this.followUpQueue.push(m); + } + + clearSteeringQueue() { + this.steeringQueue = []; + } + + clearFollowUpQueue() { + this.followUpQueue = []; + } + + clearAllQueues() { + this.steeringQueue = []; + this.followUpQueue = []; } clearMessages() { this._state.messages = []; } - /** Remove and return the last message from the message list */ - popMessage(): AgentMessage | undefined { - if (this._state.messages.length === 0) return undefined; - const popped = this._state.messages[this._state.messages.length - 1]; - this._state.messages = this._state.messages.slice(0, -1); - return popped; - } - abort() { this.abortController?.abort(); } @@ -196,13 +239,20 @@ export class Agent { this._state.streamMessage = null; this._state.pendingToolCalls = new Set(); this._state.error = undefined; - this.messageQueue = []; + this.steeringQueue = []; + this.followUpQueue = []; } /** Send a prompt with an AgentMessage */ async prompt(message: AgentMessage | AgentMessage[]): Promise; async prompt(input: string, images?: ImageContent[]): Promise; async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) { + if (this._state.isStreaming) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const model = this._state.model; if (!model) throw new Error("No model configured"); @@ -231,6 +281,10 @@ export class Agent { /** Continue from current context (for retry after overflow) */ async continue() { + if (this._state.isStreaming) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); @@ -276,23 +330,37 @@ export class Agent { const config: AgentLoopConfig = { model, reasoning, + interruptMode: this.interruptMode, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, getToolContext: this.getToolContext, - interruptMode: this.interruptMode, - getQueuedMessages: async () => { - if (this.queueMode === "one-at-a-time") { - if (this.messageQueue.length > 0) { - const first = this.messageQueue[0]; - this.messageQueue = this.messageQueue.slice(1); + getSteeringMessages: async () => { + if (this.steeringMode === "one-at-a-time") { + if (this.steeringQueue.length > 0) { + const first = this.steeringQueue[0]; + this.steeringQueue = this.steeringQueue.slice(1); return [first]; } return []; } else { - const queued = this.messageQueue.slice(); - this.messageQueue = []; - return queued; + const steering = this.steeringQueue.slice(); + this.steeringQueue = []; + return steering; + } + }, + getFollowUpMessages: async () => { + if (this.followUpMode === "one-at-a-time") { + if (this.followUpQueue.length > 0) { + const first = this.followUpQueue[0]; + this.followUpQueue = this.followUpQueue.slice(1); + return [first]; + } + return []; + } else { + const followUp = this.followUpQueue.slice(); + this.followUpQueue = []; + return followUp; } }, }; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 953b8df39..4e06289e8 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -22,6 +22,13 @@ export type StreamFn = ( export interface AgentLoopConfig extends SimpleStreamOptions { model: Model; + /** + * When to interrupt tool execution for steering messages. + * - "immediate": check after each tool call (default) + * - "wait": defer steering until the current turn completes + */ + interruptMode?: "immediate" | "wait"; + /** * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. * @@ -32,7 +39,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * @example * ```typescript * convertToLlm: (messages) => messages.flatMap(m => { - * if (m.role === "hookMessage") { + * if (m.role === "custom") { * // Convert custom message to user message * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; * } @@ -75,20 +82,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions { getApiKey?: (provider: string) => Promise | string | undefined; /** - * Returns queued messages to inject into the conversation. + * Returns steering messages to inject into the conversation mid-run. * - * Called after each turn to check for user interruptions or injected messages. - * If messages are returned, they're added to the context before the next LLM call. + * Called after each tool execution to check for user interruptions unless interruptMode is "wait". + * If messages are returned, remaining tool calls are skipped and + * these messages are added to the context before the next LLM call. */ - getQueuedMessages?: () => Promise; + getSteeringMessages?: () => Promise; /** - * Controls when queued messages interrupt tool execution. + * Returns follow-up messages to process after the agent would otherwise stop. * - * - "immediate" (default): Check queue after each tool, interrupt remaining tools if messages exist - * - "wait": Only process queued messages after the entire turn completes + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. */ - interruptMode?: "immediate" | "wait"; + getFollowUpMessages?: () => Promise; /** * Provides tool execution context, resolved per tool call. @@ -159,6 +168,8 @@ export interface RenderResultOptions { expanded: boolean; /** Whether this is a partial/streaming result */ isPartial: boolean; + /** Current spinner frame index for animated elements (optional) */ + spinnerFrame?: number; } /** diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index bbb31d9e0..79276ef3b 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -340,7 +340,7 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, - getQueuedMessages: async () => { + getSteeringMessages: async () => { // Return queued message after first tool executes if (executed.length === 1 && !queuedDelivered) { queuedDelivered = true; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 154208973..4d32d4ba6 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -93,11 +93,11 @@ describe("Agent", () => { expect(agent.state.messages).toEqual([]); }); - it("should support message queueing", async () => { + it("should support steering message queueing", async () => { const agent = new Agent(); const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() }; - agent.queueMessage(message); + agent.steer(message); // The message is queued but not yet in state.messages expect(agent.state.messages).not.toContainEqual(message); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 43bcda6ae..6d6444f55 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added - Added support for kwaipilot/kat-coder-pro model via OpenRouter +- Added OpenAI Codex responses provider with OAuth login support for ChatGPT Plus/Pro accounts +- Added Google Vertex AI provider (Gemini via Vertex) with Application Default Credentials support ### Changed @@ -263,4 +265,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. \ No newline at end of file +Initial release with multi-provider LLM support. diff --git a/packages/ai/src/bun-imports.d.ts b/packages/ai/src/bun-imports.d.ts new file mode 100644 index 000000000..729d3220f --- /dev/null +++ b/packages/ai/src/bun-imports.d.ts @@ -0,0 +1,14 @@ +/** + * Type declarations for Bun's import attributes. + * These allow importing non-JS files as text at build time. + */ + +declare module "*.md" { + const content: string; + export default content; +} + +declare module "*.txt" { + const content: string; + export default content; +} diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 012711f8a..3aa75e9fc 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -6,6 +6,7 @@ import { loginGitHubCopilot } from "./utils/oauth/github-copilot"; import { loginAntigravity } from "./utils/oauth/google-antigravity"; import { loginGeminiCli } from "./utils/oauth/google-gemini-cli"; import { getOAuthProviders } from "./utils/oauth/index"; +import { loginOpenAICodex } from "./utils/oauth/openai-codex"; import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types"; const AUTH_FILE = "auth.json"; @@ -89,6 +90,19 @@ async function login(provider: OAuthProvider): Promise { (msg) => console.log(msg), ); break; + case "openai-codex": + credentials = await loginOpenAICodex({ + onAuth: (info) => { + console.log(`\nOpen this URL in your browser:\n${info.url}`); + if (info.instructions) console.log(info.instructions); + console.log(); + }, + onPrompt: async (p) => { + return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); + }, + onProgress: (msg) => console.log(msg), + }); + break; } const auth = await loadAuth(); @@ -114,6 +128,7 @@ Providers: github-copilot GitHub Copilot google-gemini-cli Google Gemini CLI google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS) + openai-codex OpenAI Codex (ChatGPT Plus/Pro) Examples: npx @oh-my-pi/pi-ai login # interactive provider selection @@ -141,7 +156,7 @@ Examples: } console.log(); - const choice = await prompt("Enter number (1-4): "); + const choice = await prompt(`Enter number (1-${PROVIDERS.length}): `); const index = parseInt(choice, 10) - 1; if (index < 0 || index >= PROVIDERS.length) { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 63f160161..da0059563 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,3 +1,5 @@ +/// + export * from "./models"; export * from "./providers/anthropic"; export * from "./providers/google"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9c03c7b4c..f6e5e4686 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3211,8 +3211,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 32768, - maxTokens: 7168, + contextWindow: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1": { id: "deepseek/deepseek-r1", @@ -3240,13 +3240,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.39999999999999997, - output: 1.75, + input: 0.32, + output: 0.48, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, - maxTokens: 65536, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-distill-llama-70b": { id: "deepseek/deepseek-r1-distill-llama-70b", diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 8b93db4da..dbb33c2d2 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -12,27 +12,34 @@ for (const [provider, models] of Object.entries(MODELS)) { modelRegistry.set(provider, providerModels); } -type ModelApi< - TProvider extends KnownProvider, - TModelId extends keyof (typeof MODELS)[TProvider], -> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never; +type ProviderModels = typeof MODELS; +type ProviderWithModels = keyof ProviderModels; -export function getModel( +type ModelApi< + TProvider extends ProviderWithModels, + TModelId extends keyof ProviderModels[TProvider], +> = ProviderModels[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never; + +export function getModel( provider: TProvider, modelId: TModelId, -): Model> { - return modelRegistry.get(provider)?.get(modelId as string) as Model>; +): Model>; +export function getModel(provider: KnownProvider, modelId: string): Model | undefined; +export function getModel(provider: KnownProvider, modelId: string): Model | undefined { + return modelRegistry.get(provider)?.get(modelId as string) as Model | undefined; } export function getProviders(): KnownProvider[] { return Array.from(modelRegistry.keys()) as KnownProvider[]; } -export function getModels( +export function getModels( provider: TProvider, -): Model>[] { +): Model>[]; +export function getModels(provider: KnownProvider): Model[]; +export function getModels(provider: KnownProvider): Model[] { const models = modelRegistry.get(provider); - return models ? (Array.from(models.values()) as Model>[]) : []; + return models ? (Array.from(models.values()) as Model[]) : []; } export function calculateCost(model: Model, usage: Usage): Usage["cost"] { diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index d897dafbc..3d2d14f0a 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -7,7 +7,7 @@ import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from import { sanitizeSurrogates } from "../utils/sanitize-unicode"; import { transformMessages } from "./transorm-messages"; -type GoogleApiType = "google-generative-ai" | "google-gemini-cli"; +type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; /** * Convert internal messages to Gemini Content[] format. diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts new file mode 100644 index 000000000..b7e89d8d5 --- /dev/null +++ b/packages/ai/src/providers/google-vertex.ts @@ -0,0 +1,355 @@ +import { + type GenerateContentConfig, + type GenerateContentParameters, + GoogleGenAI, + type ThinkingConfig, + ThinkingLevel, +} from "@google/genai"; +import { calculateCost } from "../models"; +import type { + Api, + AssistantMessage, + Context, + Model, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + ToolCall, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import type { GoogleThinkingLevel } from "./google-gemini-cli"; +import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared"; + +export interface GoogleVertexOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { + enabled: boolean; + budgetTokens?: number; // -1 for dynamic, 0 to disable + level?: GoogleThinkingLevel; + }; + project?: string; + location?: string; +} + +const API_VERSION = "v1"; + +const THINKING_LEVEL_MAP: Record = { + THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, + MINIMAL: ThinkingLevel.MINIMAL, + LOW: ThinkingLevel.LOW, + MEDIUM: ThinkingLevel.MEDIUM, + HIGH: ThinkingLevel.HIGH, +}; + +// Counter for generating unique tool call IDs +let toolCallCounter = 0; + +export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( + model: Model<"google-vertex">, + context: Context, + options?: GoogleVertexOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-vertex" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const project = resolveProject(options); + const location = resolveLocation(options); + const client = createClient(model, project, location); + const params = buildParams(model, context, options); + const googleStream = await client.models.generateContentStream(params); + + stream.push({ type: "start", partial: output }); + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + for await (const chunk of googleStream) { + const candidate = chunk.candidates?.[0]; + if (candidate?.content?.parts) { + for (const part of candidate.content.parts) { + if (part.text !== undefined) { + const isThinking = part.thought === true; + if ( + !currentBlock || + (isThinking && currentBlock.type !== "thinking") || + (!isThinking && currentBlock.type !== "text") + ) { + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blocks.length - 1, + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + } + if (isThinking) { + currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else { + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + } + if (currentBlock.type === "thinking") { + currentBlock.thinking += part.text; + currentBlock.thinkingSignature = part.thoughtSignature; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } else { + currentBlock.text += part.text; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: part.text, + partial: output, + }); + } + } + + if (part.functionCall) { + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + currentBlock = null; + } + + const providedId = part.functionCall.id; + const needsNewId = + !providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId); + const toolCallId = needsNewId + ? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}` + : providedId; + + const toolCall: ToolCall = { + type: "toolCall", + id: toolCallId, + name: part.functionCall.name || "", + arguments: part.functionCall.args as Record, + ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), + }; + + output.content.push(toolCall); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta: JSON.stringify(toolCall.arguments), + partial: output, + }); + stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); + } + } + } + + if (candidate?.finishReason) { + output.stopReason = mapStopReason(candidate.finishReason); + if (output.content.some((b) => b.type === "toolCall")) { + output.stopReason = "toolUse"; + } + } + + if (chunk.usageMetadata) { + output.usage = { + input: chunk.usageMetadata.promptTokenCount || 0, + output: + (chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0), + cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0, + cacheWrite: 0, + totalTokens: chunk.usageMetadata.totalTokenCount || 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }; + calculateCost(model, output.usage); + } + } + + if (currentBlock) { + if (currentBlock.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + } else { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + // Remove internal index property used during streaming + for (const block of output.content) { + if ("index" in block) { + delete (block as { index?: number }).index; + } + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI { + const httpOptions: { headers?: Record } = {}; + + if (model.headers) { + httpOptions.headers = { ...model.headers }; + } + + const hasHttpOptions = Object.values(httpOptions).some(Boolean); + + return new GoogleGenAI({ + vertexai: true, + project, + location, + apiVersion: API_VERSION, + httpOptions: hasHttpOptions ? httpOptions : undefined, + }); +} + +function resolveProject(options?: GoogleVertexOptions): string { + const project = options?.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; + if (!project) { + throw new Error( + "Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.", + ); + } + return project; +} + +function resolveLocation(options?: GoogleVertexOptions): string { + const location = options?.location || process.env.GOOGLE_CLOUD_LOCATION; + if (!location) { + throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options."); + } + return location; +} + +function buildParams( + model: Model<"google-vertex">, + context: Context, + options: GoogleVertexOptions = {}, +): GenerateContentParameters { + const contents = convertMessages(model, context); + + const generationConfig: GenerateContentConfig = {}; + if (options.temperature !== undefined) { + generationConfig.temperature = options.temperature; + } + if (options.maxTokens !== undefined) { + generationConfig.maxOutputTokens = options.maxTokens; + } + + const config: GenerateContentConfig = { + ...(Object.keys(generationConfig).length > 0 && generationConfig), + ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }), + ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }), + }; + + if (context.tools && context.tools.length > 0 && options.toolChoice) { + config.toolConfig = { + functionCallingConfig: { + mode: mapToolChoice(options.toolChoice), + }, + }; + } else { + config.toolConfig = undefined; + } + + if (options.thinking?.enabled && model.reasoning) { + const thinkingConfig: ThinkingConfig = { includeThoughts: true }; + if (options.thinking.level !== undefined) { + thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level]; + } else if (options.thinking.budgetTokens !== undefined) { + thinkingConfig.thinkingBudget = options.thinking.budgetTokens; + } + config.thinkingConfig = thinkingConfig; + } + + if (options.signal) { + if (options.signal.aborted) { + throw new Error("Request aborted"); + } + config.abortSignal = options.signal; + } + + const params: GenerateContentParameters = { + model: model.id, + contents, + config, + }; + + return params; +} diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts new file mode 100644 index 000000000..cf6276d92 --- /dev/null +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -0,0 +1,619 @@ +import type { + ResponseFunctionToolCall, + ResponseInput, + ResponseInputContent, + ResponseInputImage, + ResponseInputText, + ResponseOutputMessage, + ResponseReasoningItem, +} from "openai/resources/responses/responses.js"; +import { calculateCost } from "../models"; +import { getEnvApiKey } from "../stream"; +import type { + Api, + AssistantMessage, + Context, + Model, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types"; +import { AssistantMessageEventStream } from "../utils/event-stream"; +import { parseStreamingJson } from "../utils/json-parse"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode"; +import { + CODEX_BASE_URL, + JWT_CLAIM_PATH, + OPENAI_HEADER_VALUES, + OPENAI_HEADERS, + URL_PATHS, +} from "./openai-codex/constants"; +import { getCodexInstructions } from "./openai-codex/prompts/codex"; +import { + type CodexRequestOptions, + normalizeModel, + type RequestBody, + transformRequestBody, +} from "./openai-codex/request-transformer"; +import { parseCodexError, parseCodexSseStream } from "./openai-codex/response-handler"; +import { transformMessages } from "./transorm-messages"; + +export interface OpenAICodexResponsesOptions extends StreamOptions { + reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; + textVerbosity?: "low" | "medium" | "high"; + include?: string[]; + codexMode?: boolean; +} + +const CODEX_DEBUG = process.env.PI_CODEX_DEBUG === "1" || process.env.PI_CODEX_DEBUG === "true"; + +export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = ( + model: Model<"openai-codex-responses">, + context: Context, + options?: OpenAICodexResponsesOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "openai-codex-responses" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const accountId = getAccountId(apiKey); + const baseUrl = model.baseUrl || CODEX_BASE_URL; + const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const url = rewriteUrlForCodex(new URL(URL_PATHS.RESPONSES.slice(1), baseWithSlash).toString()); + + const messages = convertMessages(model, context); + const params: RequestBody = { + model: model.id, + input: messages, + stream: true, + }; + + if (options?.maxTokens) { + params.max_output_tokens = options.maxTokens; + } + + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + + if (context.tools) { + params.tools = convertTools(context.tools); + } + + const normalizedModel = normalizeModel(params.model); + const codexInstructions = await getCodexInstructions(normalizedModel); + + const codexOptions: CodexRequestOptions = { + reasoningEffort: options?.reasoningEffort, + reasoningSummary: options?.reasoningSummary ?? undefined, + textVerbosity: options?.textVerbosity, + include: options?.include, + }; + + const transformedBody = await transformRequestBody( + params, + codexInstructions, + codexOptions, + options?.codexMode ?? true, + ); + + const headers = createCodexHeaders(model.headers, accountId, apiKey, transformedBody.prompt_cache_key); + logCodexDebug("codex request", { + url, + model: params.model, + headers: redactHeaders(headers), + }); + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: options?.signal, + }); + + logCodexDebug("codex response", { + url: response.url, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get("content-type") || null, + cfRay: response.headers.get("cf-ray") || null, + }); + + if (!response.ok) { + const info = await parseCodexError(response); + throw new Error(info.friendlyMessage || info.message); + } + + if (!response.body) { + throw new Error("No response body"); + } + + stream.push({ type: "start", partial: output }); + + let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null; + let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + + for await (const rawEvent of parseCodexSseStream(response)) { + const eventType = typeof rawEvent.type === "string" ? rawEvent.type : ""; + if (!eventType) continue; + + if (eventType === "response.output_item.added") { + const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall; + if (item.type === "reasoning") { + currentItem = item; + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "message") { + currentItem = item; + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } else if (item.type === "function_call") { + currentItem = item; + currentBlock = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: {}, + partialJson: item.arguments || "", + }; + output.content.push(currentBlock); + stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); + } + } else if (eventType === "response.reasoning_summary_part.added") { + if (currentItem && currentItem.type === "reasoning") { + currentItem.summary = currentItem.summary || []; + currentItem.summary.push((rawEvent as { part: ResponseReasoningItem["summary"][number] }).part); + } + } else if (eventType === "response.reasoning_summary_text.delta") { + if (currentItem && currentItem.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + const delta = (rawEvent as { delta?: string }).delta || ""; + currentBlock.thinking += delta; + lastPart.text += delta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } + } else if (eventType === "response.reasoning_summary_part.done") { + if (currentItem && currentItem.type === "reasoning" && currentBlock?.type === "thinking") { + currentItem.summary = currentItem.summary || []; + const lastPart = currentItem.summary[currentItem.summary.length - 1]; + if (lastPart) { + currentBlock.thinking += "\n\n"; + lastPart.text += "\n\n"; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: "\n\n", + partial: output, + }); + } + } + } else if (eventType === "response.content_part.added") { + if (currentItem && currentItem.type === "message") { + currentItem.content = currentItem.content || []; + const part = (rawEvent as { part?: ResponseOutputMessage["content"][number] }).part; + if (part && (part.type === "output_text" || part.type === "refusal")) { + currentItem.content.push(part); + } + } + } else if (eventType === "response.output_text.delta") { + if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") { + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart && lastPart.type === "output_text") { + const delta = (rawEvent as { delta?: string }).delta || ""; + currentBlock.text += delta; + lastPart.text += delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } + } else if (eventType === "response.refusal.delta") { + if (currentItem && currentItem.type === "message" && currentBlock?.type === "text") { + const lastPart = currentItem.content[currentItem.content.length - 1]; + if (lastPart && lastPart.type === "refusal") { + const delta = (rawEvent as { delta?: string }).delta || ""; + currentBlock.text += delta; + lastPart.refusal += delta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } + } else if (eventType === "response.function_call_arguments.delta") { + if (currentItem && currentItem.type === "function_call" && currentBlock?.type === "toolCall") { + const delta = (rawEvent as { delta?: string }).delta || ""; + currentBlock.partialJson += delta; + currentBlock.arguments = parseStreamingJson(currentBlock.partialJson); + stream.push({ + type: "toolcall_delta", + contentIndex: blockIndex(), + delta, + partial: output, + }); + } + } else if (eventType === "response.output_item.done") { + const item = rawEvent.item as ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall; + if (item.type === "reasoning" && currentBlock?.type === "thinking") { + currentBlock.thinking = item.summary?.map((s) => s.text).join("\n\n") || ""; + currentBlock.thinkingSignature = JSON.stringify(item); + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: currentBlock.thinking, + partial: output, + }); + currentBlock = null; + } else if (item.type === "message" && currentBlock?.type === "text") { + currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join(""); + currentBlock.textSignature = item.id; + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: currentBlock.text, + partial: output, + }); + currentBlock = null; + } else if (item.type === "function_call") { + const toolCall: ToolCall = { + type: "toolCall", + id: `${item.call_id}|${item.id}`, + name: item.name, + arguments: JSON.parse(item.arguments), + }; + stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); + } + } else if (eventType === "response.completed" || eventType === "response.done") { + const response = ( + rawEvent as { + response?: { + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + input_tokens_details?: { cached_tokens?: number }; + }; + status?: string; + }; + } + ).response; + if (response?.usage) { + const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; + output.usage = { + input: (response.usage.input_tokens || 0) - cachedTokens, + output: response.usage.output_tokens || 0, + cacheRead: cachedTokens, + cacheWrite: 0, + totalTokens: response.usage.total_tokens || 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + } + calculateCost(model, output.usage); + output.stopReason = mapStopReason(response?.status); + if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { + output.stopReason = "toolUse"; + } + } else if (eventType === "error") { + const code = (rawEvent as { code?: string }).code || ""; + const message = (rawEvent as { message?: string }).message || "Unknown error"; + throw new Error(code ? `Error Code ${code}: ${message}` : message); + } else if (eventType === "response.failed") { + throw new Error("Unknown error"); + } + } + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) delete (block as { index?: number }).index; + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +function createCodexHeaders( + initHeaders: Record | undefined, + accountId: string, + accessToken: string, + promptCacheKey?: string, +): Headers { + const headers = new Headers(initHeaders ?? {}); + headers.delete("x-api-key"); + headers.set("Authorization", `Bearer ${accessToken}`); + headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); + headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + + if (promptCacheKey) { + headers.set(OPENAI_HEADERS.CONVERSATION_ID, promptCacheKey); + headers.set(OPENAI_HEADERS.SESSION_ID, promptCacheKey); + } else { + headers.delete(OPENAI_HEADERS.CONVERSATION_ID); + headers.delete(OPENAI_HEADERS.SESSION_ID); + } + + headers.set("accept", "text/event-stream"); + headers.set("content-type", "application/json"); + return headers; +} + +function logCodexDebug(message: string, details?: Record): void { + if (!CODEX_DEBUG) return; + if (details) { + console.error(`[codex] ${message}`, details); + return; + } + console.error(`[codex] ${message}`); +} + +function redactHeaders(headers: Headers): Record { + const redacted: Record = {}; + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === "authorization") { + redacted[key] = "Bearer [redacted]"; + return; + } + if ( + lower.includes("account") || + lower.includes("session") || + lower.includes("conversation") || + lower === "cookie" + ) { + redacted[key] = "[redacted]"; + return; + } + redacted[key] = value; + }); + return redacted; +} + +function rewriteUrlForCodex(url: string): string { + return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES); +} + +type JwtPayload = { + [JWT_CLAIM_PATH]?: { + chatgpt_account_id?: string; + }; + [key: string]: unknown; +}; + +function decodeJwt(token: string): JwtPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1] ?? ""; + const decoded = Buffer.from(payload, "base64").toString("utf-8"); + return JSON.parse(decoded) as JwtPayload; + } catch { + return null; + } +} + +function getAccountId(accessToken: string): string { + const payload = decodeJwt(accessToken); + const auth = payload?.[JWT_CLAIM_PATH]; + const accountId = auth?.chatgpt_account_id; + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + return accountId; +} + +function shortHash(str: string): string { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); +} + +function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput { + const messages: ResponseInput = []; + + const transformedMessages = transformMessages(context.messages, model); + + let msgIndex = 0; + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + messages.push({ + role: "user", + content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], + }); + } else { + const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => { + if (item.type === "text") { + return { + type: "input_text", + text: sanitizeSurrogates(item.text), + } satisfies ResponseInputText; + } + return { + type: "input_image", + detail: "auto", + image_url: `data:${item.mimeType};base64,${item.data}`, + } satisfies ResponseInputImage; + }); + const filteredContent = !model.input.includes("image") + ? content.filter((c) => c.type !== "input_image") + : content; + if (filteredContent.length === 0) continue; + messages.push({ + role: "user", + content: filteredContent, + }); + } + } else if (msg.role === "assistant") { + const output: ResponseInput = []; + + for (const block of msg.content) { + if (block.type === "thinking" && msg.stopReason !== "error") { + if (block.thinkingSignature) { + const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem; + output.push(reasoningItem); + } + } else if (block.type === "text") { + const textBlock = block as TextContent; + let msgId = textBlock.textSignature; + if (!msgId) { + msgId = `msg_${msgIndex}`; + } else if (msgId.length > 64) { + msgId = `msg_${shortHash(msgId)}`; + } + output.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }], + status: "completed", + id: msgId, + } satisfies ResponseOutputMessage); + } else if (block.type === "toolCall" && msg.stopReason !== "error") { + const toolCall = block as ToolCall; + output.push({ + type: "function_call", + id: toolCall.id.split("|")[1], + call_id: toolCall.id.split("|")[0], + name: toolCall.name, + arguments: JSON.stringify(toolCall.arguments), + }); + } + } + if (output.length === 0) continue; + messages.push(...output); + } else if (msg.role === "toolResult") { + const textResult = msg.content + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text) + .join("\n"); + const hasImages = msg.content.some((c) => c.type === "image"); + + const hasText = textResult.length > 0; + messages.push({ + type: "function_call_output", + call_id: msg.toolCallId.split("|")[0], + output: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), + }); + + if (hasImages && model.input.includes("image")) { + const contentParts: ResponseInputContent[] = []; + contentParts.push({ + type: "input_text", + text: "Attached image(s) from tool result:", + } satisfies ResponseInputText); + + for (const block of msg.content) { + if (block.type === "image") { + contentParts.push({ + type: "input_image", + detail: "auto", + image_url: `data:${block.mimeType};base64,${block.data}`, + } satisfies ResponseInputImage); + } + } + + messages.push({ + role: "user", + content: contentParts, + }); + } + } + msgIndex++; + } + + return messages; +} + +function convertTools( + tools: Tool[], +): Array<{ type: "function"; name: string; description: string; parameters: Record; strict: null }> { + return tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters as unknown as Record, + strict: null, + })); +} + +function mapStopReason(status: string | undefined): StopReason { + if (!status) return "stop"; + switch (status) { + case "completed": + return "stop"; + case "incomplete": + return "length"; + case "failed": + case "cancelled": + return "error"; + case "in_progress": + case "queued": + return "stop"; + default: + return "stop"; + } +} diff --git a/packages/ai/src/providers/openai-codex/constants.ts b/packages/ai/src/providers/openai-codex/constants.ts new file mode 100644 index 000000000..6bb2cf216 --- /dev/null +++ b/packages/ai/src/providers/openai-codex/constants.ts @@ -0,0 +1,25 @@ +/** + * Constants for OpenAI Codex (ChatGPT OAuth) backend + */ + +export const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; + +export const OPENAI_HEADERS = { + BETA: "OpenAI-Beta", + ACCOUNT_ID: "chatgpt-account-id", + ORIGINATOR: "originator", + SESSION_ID: "session_id", + CONVERSATION_ID: "conversation_id", +} as const; + +export const OPENAI_HEADER_VALUES = { + BETA_RESPONSES: "responses=experimental", + ORIGINATOR_CODEX: "codex_cli_rs", +} as const; + +export const URL_PATHS = { + RESPONSES: "/responses", + CODEX_RESPONSES: "/codex/responses", +} as const; + +export const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; diff --git a/packages/ai/src/providers/openai-codex/prompts/codex-instructions.md b/packages/ai/src/providers/openai-codex/prompts/codex-instructions.md new file mode 100644 index 000000000..e2f901787 --- /dev/null +++ b/packages/ai/src/providers/openai-codex/prompts/codex-instructions.md @@ -0,0 +1,105 @@ +You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +## General + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) + +## Editing constraints + +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. + +## Plan tool + +When using the planning tool: +- Skip using the planning tool for straightforward tasks (roughly the easiest 25%). +- Do not make single-step plans. +- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. + +## Codex CLI harness, sandboxing, and approvals + +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. + +Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. + +Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: +- **restricted**: Requires approval +- **enabled**: No approval needed + +Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are +- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. +- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. +- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) +- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. + +When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) +- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. +- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for +- (for all of these, you should weigh alternative paths that do not require approval) + +When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. + +You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. + +Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. + +When requesting approval to execute a command that will require escalated privileges: + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter + +## Special user requests + +- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. +- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps. + +## Presenting your work and final message + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +- Default: be very concise; friendly coding teammate tone. +- Ask only when needed; suggest ideas; mirror the user's style. +- For substantial work, summarize clearly; follow final‑answer formatting. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only. +- No "save/copy this file" - User is on the same machine. +- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. +- For code changes: + * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. + * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. +- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. + +### Final answer structure and style guidelines + +- Plain text; CLI handles styling. Use structure only when it helps scanability. +- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help. +- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent. +- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. +- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. +- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording. +- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers. +- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets. +- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 diff --git a/packages/ai/src/providers/openai-codex/prompts/codex.ts b/packages/ai/src/providers/openai-codex/prompts/codex.ts new file mode 100644 index 000000000..13a995e49 --- /dev/null +++ b/packages/ai/src/providers/openai-codex/prompts/codex.ts @@ -0,0 +1,217 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import FALLBACK_INSTRUCTIONS from "./codex-instructions.md" with { type: "text" }; + +const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest"; +const GITHUB_HTML_RELEASES = "https://github.com/openai/codex/releases/latest"; + +const DEFAULT_AGENT_DIR = join(homedir(), ".pi", "agent"); + +function getAgentDir(): string { + return process.env.PI_CODING_AGENT_DIR || DEFAULT_AGENT_DIR; +} + +function getCacheDir(): string { + return join(getAgentDir(), "cache", "openai-codex"); +} + +export type ModelFamily = "gpt-5.2-codex" | "codex-max" | "codex" | "gpt-5.2" | "gpt-5.1"; + +const PROMPT_FILES: Record = { + "gpt-5.2-codex": "gpt-5.2-codex_prompt.md", + "codex-max": "gpt-5.1-codex-max_prompt.md", + codex: "gpt_5_codex_prompt.md", + "gpt-5.2": "gpt_5_2_prompt.md", + "gpt-5.1": "gpt_5_1_prompt.md", +}; + +const CACHE_FILES: Record = { + "gpt-5.2-codex": "gpt-5.2-codex-instructions.md", + "codex-max": "codex-max-instructions.md", + codex: "codex-instructions.md", + "gpt-5.2": "gpt-5.2-instructions.md", + "gpt-5.1": "gpt-5.1-instructions.md", +}; + +export type CacheMetadata = { + etag: string | null; + tag: string; + lastChecked: number; + url: string; +}; + +export function getModelFamily(normalizedModel: string): ModelFamily { + if (normalizedModel.includes("gpt-5.2-codex") || normalizedModel.includes("gpt 5.2 codex")) { + return "gpt-5.2-codex"; + } + if (normalizedModel.includes("codex-max")) { + return "codex-max"; + } + if (normalizedModel.includes("codex") || normalizedModel.startsWith("codex-")) { + return "codex"; + } + if (normalizedModel.includes("gpt-5.2")) { + return "gpt-5.2"; + } + return "gpt-5.1"; +} + +async function getLatestReleaseTag(): Promise { + try { + const response = await fetch(GITHUB_API_RELEASES); + if (response.ok) { + const data = (await response.json()) as { tag_name?: string }; + if (data.tag_name) { + return data.tag_name; + } + } + } catch { + // fallback + } + + const htmlResponse = await fetch(GITHUB_HTML_RELEASES); + if (!htmlResponse.ok) { + throw new Error(`Failed to fetch latest release: ${htmlResponse.status}`); + } + + const finalUrl = htmlResponse.url; + if (finalUrl) { + const parts = finalUrl.split("/tag/"); + const last = parts[parts.length - 1]; + if (last && !last.includes("/")) { + return last; + } + } + + const html = await htmlResponse.text(); + const match = html.match(/\/openai\/codex\/releases\/tag\/([^"]+)/); + if (match?.[1]) { + return match[1]; + } + + throw new Error("Failed to determine latest release tag from GitHub"); +} + +export async function getCodexInstructions(normalizedModel = "gpt-5.1-codex"): Promise { + const modelFamily = getModelFamily(normalizedModel); + const promptFile = PROMPT_FILES[modelFamily]; + const cacheDir = getCacheDir(); + const cacheFile = join(cacheDir, CACHE_FILES[modelFamily]); + const cacheMetaFile = join(cacheDir, `${CACHE_FILES[modelFamily].replace(".md", "-meta.json")}`); + + try { + let cachedETag: string | null = null; + let cachedTag: string | null = null; + let cachedTimestamp: number | null = null; + + if (existsSync(cacheMetaFile)) { + const metadata = JSON.parse(readFileSync(cacheMetaFile, "utf-8")) as CacheMetadata; + cachedETag = metadata.etag; + cachedTag = metadata.tag; + cachedTimestamp = metadata.lastChecked; + } + + const CACHE_TTL_MS = 15 * 60 * 1000; + if (cachedTimestamp && Date.now() - cachedTimestamp < CACHE_TTL_MS && existsSync(cacheFile)) { + return readFileSync(cacheFile, "utf-8"); + } + + const latestTag = await getLatestReleaseTag(); + const instructionsUrl = `https://raw.githubusercontent.com/openai/codex/${latestTag}/codex-rs/core/${promptFile}`; + + if (cachedTag !== latestTag) { + cachedETag = null; + } + + const headers: Record = {}; + if (cachedETag) { + headers["If-None-Match"] = cachedETag; + } + + const response = await fetch(instructionsUrl, { headers }); + + if (response.status === 304) { + if (existsSync(cacheFile)) { + return readFileSync(cacheFile, "utf-8"); + } + } + + if (response.ok) { + const instructions = await response.text(); + const newETag = response.headers.get("etag"); + + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, instructions, "utf-8"); + writeFileSync( + cacheMetaFile, + JSON.stringify({ + etag: newETag, + tag: latestTag, + lastChecked: Date.now(), + url: instructionsUrl, + } satisfies CacheMetadata), + "utf-8", + ); + + return instructions; + } + + throw new Error(`HTTP ${response.status}`); + } catch (error) { + console.error( + `[openai-codex] Failed to fetch ${modelFamily} instructions from GitHub:`, + error instanceof Error ? error.message : String(error), + ); + + if (existsSync(cacheFile)) { + console.error(`[openai-codex] Using cached ${modelFamily} instructions`); + return readFileSync(cacheFile, "utf-8"); + } + + console.error(`[openai-codex] Falling back to bundled instructions for ${modelFamily}`); + return FALLBACK_INSTRUCTIONS; + } +} + +export const TOOL_REMAP_MESSAGE = ` + +YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references. + + + + +❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD +- NEVER use: apply_patch, applyPatch +- ALWAYS use: edit tool for ALL file modifications + + + +❌ UPDATE_PLAN DOES NOT EXIST +- NEVER use: update_plan, updatePlan, read_plan, readPlan, todowrite, todoread +- There is no plan tool in this environment + + + + +File Operations: + • read - Read file contents + • edit - Modify files with exact find/replace + • write - Create or overwrite files + +Search/Discovery: + • grep - Search file contents for patterns (read-only) + • find - Find files by glob pattern (read-only) + • ls - List directory contents (read-only) + +Execution: + • bash - Run shell commands + + + +Before file modifications: +1. Am I using "edit" NOT "apply_patch"? +2. Am I avoiding plan tools entirely? +3. Am I using only the tools listed above? + +`; diff --git a/packages/ai/src/providers/openai-codex/prompts/pi-codex-bridge.ts b/packages/ai/src/providers/openai-codex/prompts/pi-codex-bridge.ts new file mode 100644 index 000000000..b6e2250cb --- /dev/null +++ b/packages/ai/src/providers/openai-codex/prompts/pi-codex-bridge.ts @@ -0,0 +1,48 @@ +/** + * Codex-Pi bridge prompt + * Aligns Codex CLI expectations with Pi's toolset. + */ + +export const CODEX_PI_BRIDGE = `# Codex Running in Pi + +You are running Codex through pi, a terminal coding assistant. The tools and rules differ from Codex CLI. + +## CRITICAL: Tool Replacements + + +❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD +- NEVER use: apply_patch, applyPatch +- ALWAYS use: edit for ALL file modifications + + + +❌ UPDATE_PLAN DOES NOT EXIST +- NEVER use: update_plan, updatePlan, read_plan, readPlan, todowrite, todoread +- There is no plan tool in this environment + + +## Available Tools (pi) + +- read - Read file contents +- bash - Execute bash commands +- edit - Modify files with exact find/replace (requires prior read) +- write - Create or overwrite files +- grep - Search file contents (read-only) +- find - Find files by glob pattern (read-only) +- ls - List directory contents (read-only) + +## Usage Rules + +- Read before edit; use read instead of cat/sed for file contents +- Use edit for surgical changes; write only for new files or complete rewrites +- Prefer grep/find/ls over bash for discovery +- Be concise and show file paths clearly when working with files + +## Verification Checklist + +1. Using edit, not apply_patch +2. No plan tools used +3. Only the tools listed above are called + +Below are additional system instruction you MUST follow when responding: +`; diff --git a/packages/ai/src/providers/openai-codex/request-transformer.ts b/packages/ai/src/providers/openai-codex/request-transformer.ts new file mode 100644 index 000000000..2ed831c64 --- /dev/null +++ b/packages/ai/src/providers/openai-codex/request-transformer.ts @@ -0,0 +1,328 @@ +import { TOOL_REMAP_MESSAGE } from "./prompts/codex"; +import { CODEX_PI_BRIDGE } from "./prompts/pi-codex-bridge"; + +export interface ReasoningConfig { + effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + summary: "auto" | "concise" | "detailed" | "off" | "on"; +} + +export interface CodexRequestOptions { + reasoningEffort?: ReasoningConfig["effort"]; + reasoningSummary?: ReasoningConfig["summary"] | null; + textVerbosity?: "low" | "medium" | "high"; + include?: string[]; +} + +export interface InputItem { + id?: string | null; + type?: string | null; + role?: string; + content?: unknown; + call_id?: string | null; + name?: string; + output?: unknown; + arguments?: string; +} + +export interface RequestBody { + model: string; + store?: boolean; + stream?: boolean; + instructions?: string; + input?: InputItem[]; + tools?: unknown; + temperature?: number; + reasoning?: Partial; + text?: { + verbosity?: "low" | "medium" | "high"; + }; + include?: string[]; + prompt_cache_key?: string; + max_output_tokens?: number; + max_completion_tokens?: number; + [key: string]: unknown; +} + +const MODEL_MAP: Record = { + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-low": "gpt-5.1-codex", + "gpt-5.1-codex-medium": "gpt-5.1-codex", + "gpt-5.1-codex-high": "gpt-5.1-codex", + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-low": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-medium": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-high": "gpt-5.1-codex-max", + "gpt-5.1-codex-max-xhigh": "gpt-5.1-codex-max", + "gpt-5.2": "gpt-5.2", + "gpt-5.2-none": "gpt-5.2", + "gpt-5.2-low": "gpt-5.2", + "gpt-5.2-medium": "gpt-5.2", + "gpt-5.2-high": "gpt-5.2", + "gpt-5.2-xhigh": "gpt-5.2", + "gpt-5.2-codex": "gpt-5.2-codex", + "gpt-5.2-codex-low": "gpt-5.2-codex", + "gpt-5.2-codex-medium": "gpt-5.2-codex", + "gpt-5.2-codex-high": "gpt-5.2-codex", + "gpt-5.2-codex-xhigh": "gpt-5.2-codex", + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-medium": "gpt-5.1-codex-mini", + "gpt-5.1-codex-mini-high": "gpt-5.1-codex-mini", + "gpt-5.1": "gpt-5.1", + "gpt-5.1-none": "gpt-5.1", + "gpt-5.1-low": "gpt-5.1", + "gpt-5.1-medium": "gpt-5.1", + "gpt-5.1-high": "gpt-5.1", + "gpt-5.1-chat-latest": "gpt-5.1", + "gpt-5-codex": "gpt-5.1-codex", + "codex-mini-latest": "gpt-5.1-codex-mini", + "gpt-5-codex-mini": "gpt-5.1-codex-mini", + "gpt-5-codex-mini-medium": "gpt-5.1-codex-mini", + "gpt-5-codex-mini-high": "gpt-5.1-codex-mini", + "gpt-5": "gpt-5.1", + "gpt-5-mini": "gpt-5.1", + "gpt-5-nano": "gpt-5.1", +}; + +function getNormalizedModel(modelId: string): string | undefined { + if (MODEL_MAP[modelId]) return MODEL_MAP[modelId]; + const lowerModelId = modelId.toLowerCase(); + const match = Object.keys(MODEL_MAP).find((key) => key.toLowerCase() === lowerModelId); + return match ? MODEL_MAP[match] : undefined; +} + +export function normalizeModel(model: string | undefined): string { + if (!model) return "gpt-5.1"; + + const modelId = model.includes("/") ? model.split("/").pop()! : model; + const mappedModel = getNormalizedModel(modelId); + if (mappedModel) return mappedModel; + + const normalized = modelId.toLowerCase(); + + if (normalized.includes("gpt-5.2-codex") || normalized.includes("gpt 5.2 codex")) { + return "gpt-5.2-codex"; + } + if (normalized.includes("gpt-5.2") || normalized.includes("gpt 5.2")) { + return "gpt-5.2"; + } + if (normalized.includes("gpt-5.1-codex-max") || normalized.includes("gpt 5.1 codex max")) { + return "gpt-5.1-codex-max"; + } + if (normalized.includes("gpt-5.1-codex-mini") || normalized.includes("gpt 5.1 codex mini")) { + return "gpt-5.1-codex-mini"; + } + if ( + normalized.includes("codex-mini-latest") || + normalized.includes("gpt-5-codex-mini") || + normalized.includes("gpt 5 codex mini") + ) { + return "codex-mini-latest"; + } + if (normalized.includes("gpt-5.1-codex") || normalized.includes("gpt 5.1 codex")) { + return "gpt-5.1-codex"; + } + if (normalized.includes("gpt-5.1") || normalized.includes("gpt 5.1")) { + return "gpt-5.1"; + } + if (normalized.includes("codex")) { + return "gpt-5.1-codex"; + } + if (normalized.includes("gpt-5") || normalized.includes("gpt 5")) { + return "gpt-5.1"; + } + + return "gpt-5.1"; +} + +function getReasoningConfig(modelName: string | undefined, options: CodexRequestOptions = {}): ReasoningConfig { + const normalizedName = modelName?.toLowerCase() ?? ""; + + const isGpt52Codex = normalizedName.includes("gpt-5.2-codex") || normalizedName.includes("gpt 5.2 codex"); + const isGpt52General = (normalizedName.includes("gpt-5.2") || normalizedName.includes("gpt 5.2")) && !isGpt52Codex; + const isCodexMax = normalizedName.includes("codex-max") || normalizedName.includes("codex max"); + const isCodexMini = + normalizedName.includes("codex-mini") || + normalizedName.includes("codex mini") || + normalizedName.includes("codex_mini") || + normalizedName.includes("codex-mini-latest"); + const isCodex = normalizedName.includes("codex") && !isCodexMini; + const isLightweight = !isCodexMini && (normalizedName.includes("nano") || normalizedName.includes("mini")); + const isGpt51General = + (normalizedName.includes("gpt-5.1") || normalizedName.includes("gpt 5.1")) && + !isCodex && + !isCodexMax && + !isCodexMini; + + const supportsXhigh = isGpt52General || isGpt52Codex || isCodexMax; + const supportsNone = isGpt52General || isGpt51General; + + const defaultEffort: ReasoningConfig["effort"] = isCodexMini + ? "medium" + : supportsXhigh + ? "high" + : isLightweight + ? "minimal" + : "medium"; + + let effort = options.reasoningEffort || defaultEffort; + + if (isCodexMini) { + if (effort === "minimal" || effort === "low" || effort === "none") { + effort = "medium"; + } + if (effort === "xhigh") { + effort = "high"; + } + if (effort !== "high" && effort !== "medium") { + effort = "medium"; + } + } + + if (!supportsXhigh && effort === "xhigh") { + effort = "high"; + } + + if (!supportsNone && effort === "none") { + effort = "low"; + } + + if (isCodex && effort === "minimal") { + effort = "low"; + } + + return { + effort, + summary: options.reasoningSummary ?? "auto", + }; +} + +function filterInput(input: InputItem[] | undefined): InputItem[] | undefined { + if (!Array.isArray(input)) return input; + + return input + .filter((item) => item.type !== "item_reference") + .map((item) => { + if (item.id != null) { + const { id: _id, ...rest } = item; + return rest as InputItem; + } + return item; + }); +} + +function addCodexBridgeMessage( + input: InputItem[] | undefined, + hasTools: boolean, + systemPrompt?: string, +): InputItem[] | undefined { + if (!hasTools || !Array.isArray(input)) return input; + + const bridgeText = systemPrompt ? `${CODEX_PI_BRIDGE}\n\n${systemPrompt}` : CODEX_PI_BRIDGE; + + const bridgeMessage: InputItem = { + type: "message", + role: "developer", + content: [ + { + type: "input_text", + text: bridgeText, + }, + ], + }; + + return [bridgeMessage, ...input]; +} + +function addToolRemapMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined { + if (!hasTools || !Array.isArray(input)) return input; + + const toolRemapMessage: InputItem = { + type: "message", + role: "developer", + content: [ + { + type: "input_text", + text: TOOL_REMAP_MESSAGE, + }, + ], + }; + + return [toolRemapMessage, ...input]; +} + +export async function transformRequestBody( + body: RequestBody, + codexInstructions: string, + options: CodexRequestOptions = {}, + codexMode = true, + systemPrompt?: string, +): Promise { + const normalizedModel = normalizeModel(body.model); + + body.model = normalizedModel; + body.store = false; + body.stream = true; + body.instructions = codexInstructions; + + if (body.input && Array.isArray(body.input)) { + body.input = filterInput(body.input); + + if (codexMode) { + body.input = addCodexBridgeMessage(body.input, !!body.tools, systemPrompt); + } else { + body.input = addToolRemapMessage(body.input, !!body.tools); + } + + if (body.input) { + const functionCallIds = new Set( + body.input + .filter((item) => item.type === "function_call" && typeof item.call_id === "string") + .map((item) => item.call_id as string), + ); + + body.input = body.input.map((item) => { + if (item.type === "function_call_output" && typeof item.call_id === "string") { + const callId = item.call_id as string; + if (!functionCallIds.has(callId)) { + const itemRecord = item as unknown as Record; + const toolName = typeof itemRecord.name === "string" ? itemRecord.name : "tool"; + let text = ""; + try { + const output = itemRecord.output; + text = typeof output === "string" ? output : JSON.stringify(output); + } catch { + text = String(itemRecord.output ?? ""); + } + if (text.length > 16000) { + text = `${text.slice(0, 16000)}\n...[truncated]`; + } + return { + type: "message", + role: "assistant", + content: `[Previous ${toolName} result; call_id=${callId}]: ${text}`, + } as InputItem; + } + } + return item; + }); + } + } + + const reasoningConfig = getReasoningConfig(normalizedModel, options); + body.reasoning = { + ...body.reasoning, + ...reasoningConfig, + }; + + body.text = { + ...body.text, + verbosity: options.textVerbosity || "medium", + }; + + body.include = options.include || ["reasoning.encrypted_content"]; + + delete body.max_output_tokens; + delete body.max_completion_tokens; + + return body; +} diff --git a/packages/ai/src/providers/openai-codex/response-handler.ts b/packages/ai/src/providers/openai-codex/response-handler.ts new file mode 100644 index 000000000..e6cfb12fd --- /dev/null +++ b/packages/ai/src/providers/openai-codex/response-handler.ts @@ -0,0 +1,133 @@ +export type CodexRateLimit = { + used_percent?: number; + window_minutes?: number; + resets_at?: number; +}; + +export type CodexRateLimits = { + primary?: CodexRateLimit; + secondary?: CodexRateLimit; +}; + +export type CodexErrorInfo = { + message: string; + status: number; + friendlyMessage?: string; + rateLimits?: CodexRateLimits; + raw?: string; +}; + +export async function parseCodexError(response: Response): Promise { + const raw = await response.text(); + let message = raw || response.statusText || "Request failed"; + let friendlyMessage: string | undefined; + let rateLimits: CodexRateLimits | undefined; + + try { + const parsed = JSON.parse(raw) as { error?: Record }; + const err = parsed?.error ?? {}; + + const headers = response.headers; + const primary = { + used_percent: toNumber(headers.get("x-codex-primary-used-percent")), + window_minutes: toInt(headers.get("x-codex-primary-window-minutes")), + resets_at: toInt(headers.get("x-codex-primary-reset-at")), + }; + const secondary = { + used_percent: toNumber(headers.get("x-codex-secondary-used-percent")), + window_minutes: toInt(headers.get("x-codex-secondary-window-minutes")), + resets_at: toInt(headers.get("x-codex-secondary-reset-at")), + }; + rateLimits = + primary.used_percent !== undefined || secondary.used_percent !== undefined + ? { primary, secondary } + : undefined; + + const code = String((err as { code?: string; type?: string }).code ?? (err as { type?: string }).type ?? ""); + const resetsAt = (err as { resets_at?: number }).resets_at ?? primary.resets_at ?? secondary.resets_at; + const mins = resetsAt ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) : undefined; + + if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) { + const planType = (err as { plan_type?: string }).plan_type; + const plan = planType ? ` (${String(planType).toLowerCase()} plan)` : ""; + const when = mins !== undefined ? ` Try again in ~${mins} min.` : ""; + friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${when}`.trim(); + } + + const errMessage = (err as { message?: string }).message; + message = errMessage || friendlyMessage || message; + } catch { + // raw body not JSON + } + + return { + message, + status: response.status, + friendlyMessage, + rateLimits, + raw: raw, + }; +} + +export async function* parseCodexSseStream(response: Response): AsyncGenerator> { + if (!response.body) { + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let index = buffer.indexOf("\n\n"); + while (index !== -1) { + const chunk = buffer.slice(0, index); + buffer = buffer.slice(index + 2); + const event = parseSseChunk(chunk); + if (event) yield event; + index = buffer.indexOf("\n\n"); + } + } + + if (buffer.trim()) { + const event = parseSseChunk(buffer); + if (event) yield event; + } +} + +function parseSseChunk(chunk: string): Record | null { + const lines = chunk.split("\n"); + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trim()); + } + } + + if (dataLines.length === 0) return null; + const data = dataLines.join("\n").trim(); + if (!data || data === "[DONE]") return null; + + try { + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +function toNumber(v: string | null): number | undefined { + if (v == null) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} + +function toInt(v: string | null): number | undefined { + if (v == null) return undefined; + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : undefined; +} diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 12af5084c..c895db09c 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { supportsXhigh } from "./models"; import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic"; import { type GoogleOptions, streamGoogle } from "./providers/google"; @@ -6,6 +9,8 @@ import { type GoogleThinkingLevel, streamGoogleGeminiCli, } from "./providers/google-gemini-cli"; +import { type GoogleVertexOptions, streamGoogleVertex } from "./providers/google-vertex"; +import { type OpenAICodexResponsesOptions, streamOpenAICodexResponses } from "./providers/openai-codex-responses"; import { type OpenAICompletionsOptions, streamOpenAICompletions } from "./providers/openai-completions"; import { type OpenAIResponsesOptions, streamOpenAIResponses } from "./providers/openai-responses"; import type { @@ -20,6 +25,17 @@ import type { SimpleStreamOptions, } from "./types"; +const VERTEX_ADC_CREDENTIALS_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json"); + +let cachedVertexAdcCredentialsExists: boolean | null = null; + +function hasVertexAdcCredentials(): boolean { + if (cachedVertexAdcCredentialsExists === null) { + cachedVertexAdcCredentialsExists = existsSync(VERTEX_ADC_CREDENTIALS_PATH); + } + return cachedVertexAdcCredentialsExists; +} + /** * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY. * @@ -38,6 +54,19 @@ export function getEnvApiKey(provider: any): string | undefined { return process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; } + // Vertex AI uses Application Default Credentials, not API keys. + // Auth is configured via `gcloud auth application-default login`. + if (provider === "google-vertex") { + const hasCredentials = hasVertexAdcCredentials(); + const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT); + const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION; + + if (hasCredentials && hasProject && hasLocation) { + return ""; + } + return undefined; + } + const envMap: Record = { openai: "OPENAI_API_KEY", google: "GEMINI_API_KEY", @@ -58,6 +87,11 @@ export function stream( context: Context, options?: OptionsForApi, ): AssistantMessageEventStream { + // Vertex AI uses Application Default Credentials, not API keys + if (model.api === "google-vertex") { + return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions); + } + const apiKey = options?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); @@ -75,6 +109,9 @@ export function stream( case "openai-responses": return streamOpenAIResponses(model as Model<"openai-responses">, context, providerOptions as any); + case "openai-codex-responses": + return streamOpenAICodexResponses(model as Model<"openai-codex-responses">, context, providerOptions as any); + case "google-generative-ai": return streamGoogle(model as Model<"google-generative-ai">, context, providerOptions); @@ -107,6 +144,12 @@ export function streamSimple( context: Context, options?: SimpleStreamOptions, ): AssistantMessageEventStream { + // Vertex AI uses Application Default Credentials, not API keys + if (model.api === "google-vertex") { + const providerOptions = mapOptionsForApi(model, options, undefined); + return stream(model, context, providerOptions); + } + const apiKey = options?.apiKey || getEnvApiKey(model.provider); if (!apiKey) { throw new Error(`No API key for provider: ${model.provider}`); @@ -147,6 +190,8 @@ function mapOptionsForApi( return { ...base, thinkingEnabled: false } satisfies AnthropicOptions; } + // Claude requires max_tokens > thinking.budget_tokens + // So we need to ensure maxTokens accounts for both thinking and output const anthropicBudgets = { minimal: 1024, low: 2048, @@ -154,10 +199,21 @@ function mapOptionsForApi( high: 16384, }; + const minOutputTokens = 1024; + let thinkingBudget = anthropicBudgets[clampReasoning(options.reasoning)!]; + // Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit + const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens); + + // If not enough room for thinking + output, reduce thinking budget + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + return { ...base, + maxTokens, thinkingEnabled: true, - thinkingBudgetTokens: anthropicBudgets[clampReasoning(options.reasoning)!], + thinkingBudgetTokens: thinkingBudget, } satisfies AnthropicOptions; } @@ -173,6 +229,12 @@ function mapOptionsForApi( reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning), } satisfies OpenAIResponsesOptions; + case "openai-codex-responses": + return { + ...base, + reasoningEffort: supportsXhigh(model) ? options?.reasoning : clampReasoning(options?.reasoning), + } satisfies OpenAICodexResponsesOptions; + case "google-generative-ai": { // Explicitly disable thinking when reasoning is not specified // This is needed because Gemini has "dynamic thinking" enabled by default @@ -222,7 +284,9 @@ function mapOptionsForApi( } satisfies GoogleGeminiCliOptions; } - // Gemini 2.x models use thinkingBudget + // Models using thinkingBudget (Gemini 2.x, Claude via Antigravity) + // Claude requires max_tokens > thinking.budget_tokens + // So we need to ensure maxTokens accounts for both thinking and output const budgets: Record = { minimal: 1024, low: 2048, @@ -230,13 +294,55 @@ function mapOptionsForApi( high: 16384, }; + const minOutputTokens = 1024; + let thinkingBudget = budgets[effort]; + // Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit + const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens); + + // If not enough room for thinking + output, reduce thinking budget + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + + return { + ...base, + maxTokens, + thinking: { + enabled: true, + budgetTokens: thinkingBudget, + }, + } satisfies GoogleGeminiCliOptions; + } + + case "google-vertex": { + // Explicitly disable thinking when reasoning is not specified + // This is needed because Gemini has "dynamic thinking" enabled by default + if (!options?.reasoning) { + return { ...base, thinking: { enabled: false } } satisfies GoogleVertexOptions; + } + + const googleModel = model as Model<"google-vertex">; + const effort = clampReasoning(options.reasoning)!; + + // Gemini 3 models use thinkingLevel exclusively instead of thinkingBudget. + // https://ai.google.dev/gemini-api/docs/thinking#set-budget + if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel)) { + return { + ...base, + thinking: { + enabled: true, + level: getGemini3ThinkingLevel(effort, googleModel), + }, + } satisfies GoogleVertexOptions; + } + return { ...base, thinking: { enabled: true, - budgetTokens: budgets[effort], + budgetTokens: getGoogleBudget(googleModel, effort), }, - } satisfies GoogleGeminiCliOptions; + } satisfies GoogleVertexOptions; } default: { @@ -249,19 +355,19 @@ function mapOptionsForApi( type ClampedReasoningEffort = Exclude; -function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { +function isGemini3ProModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean { // Covers gemini-3-pro, gemini-3-pro-preview, and possible other prefixed ids in the future return model.id.includes("3-pro"); } -function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean { +function isGemini3FlashModel(model: Model<"google-generative-ai"> | Model<"google-vertex">): boolean { // Covers gemini-3-flash, gemini-3-flash-preview, and possible other prefixed ids in the future return model.id.includes("3-flash"); } function getGemini3ThinkingLevel( effort: ClampedReasoningEffort, - model: Model<"google-generative-ai">, + model: Model<"google-generative-ai"> | Model<"google-vertex">, ): GoogleThinkingLevel { if (isGemini3ProModel(model)) { // Gemini 3 Pro only supports LOW/HIGH (for now) @@ -312,7 +418,10 @@ function getGeminiCliThinkingLevel(effort: ClampedReasoningEffort, modelId: stri } } -function getGoogleBudget(model: Model<"google-generative-ai">, effort: ClampedReasoningEffort): number { +function getGoogleBudget( + model: Model<"google-generative-ai"> | Model<"google-vertex">, + effort: ClampedReasoningEffort, +): number { // See https://ai.google.dev/gemini-api/docs/thinking#set-budget if (model.id.includes("2.5-pro")) { const budgets: Record = { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index fd41c2781..3d7f4cdfd 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,6 +1,8 @@ import type { AnthropicOptions } from "./providers/anthropic"; import type { GoogleOptions } from "./providers/google"; import type { GoogleGeminiCliOptions } from "./providers/google-gemini-cli"; +import type { GoogleVertexOptions } from "./providers/google-vertex"; +import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; import type { OpenAICompletionsOptions } from "./providers/openai-completions"; import type { OpenAIResponsesOptions } from "./providers/openai-responses"; import type { AssistantMessageEventStream } from "./utils/event-stream"; @@ -10,16 +12,20 @@ export type { AssistantMessageEventStream } from "./utils/event-stream"; export type Api = | "openai-completions" | "openai-responses" + | "openai-codex-responses" | "anthropic-messages" | "google-generative-ai" - | "google-gemini-cli"; + | "google-gemini-cli" + | "google-vertex"; export interface ApiOptionsMap { "anthropic-messages": AnthropicOptions; "openai-completions": OpenAICompletionsOptions; "openai-responses": OpenAIResponsesOptions; + "openai-codex-responses": OpenAICodexResponsesOptions; "google-generative-ai": GoogleOptions; "google-gemini-cli": GoogleGeminiCliOptions; + "google-vertex": GoogleVertexOptions; } // Compile-time exhaustiveness check - this will fail if ApiOptionsMap doesn't have all KnownApi keys @@ -39,7 +45,9 @@ export type KnownProvider = | "google" | "google-gemini-cli" | "google-antigravity" + | "google-vertex" | "openai" + | "openai-codex" | "github-copilot" | "xai" | "groq" diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 137632dcd..c66949f4a 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -28,6 +28,11 @@ export { loginGeminiCli, refreshGoogleCloudToken, } from "./google-gemini-cli"; +// OpenAI Codex (ChatGPT OAuth) +export { + loginOpenAICodex, + refreshOpenAICodexToken, +} from "./openai-codex"; export * from "./types"; @@ -39,6 +44,7 @@ import { refreshAnthropicToken } from "./anthropic"; import { refreshGitHubCopilotToken } from "./github-copilot"; import { refreshAntigravityToken } from "./google-antigravity"; import { refreshGoogleCloudToken } from "./google-gemini-cli"; +import { refreshOpenAICodexToken } from "./openai-codex"; import type { OAuthCredentials, OAuthProvider, OAuthProviderInfo } from "./types"; /** @@ -74,6 +80,9 @@ export async function refreshOAuthToken( } newCredentials = await refreshAntigravityToken(credentials.refresh, credentials.projectId); break; + case "openai-codex": + newCredentials = await refreshOpenAICodexToken(credentials.refresh); + break; default: throw new Error(`Unknown OAuth provider: ${provider}`); } @@ -139,5 +148,10 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "Antigravity (Gemini 3, Claude, GPT-OSS)", available: true, }, + { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + available: true, + }, ]; } diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts new file mode 100644 index 000000000..8b1870ff7 --- /dev/null +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -0,0 +1,334 @@ +/** + * OpenAI Codex (ChatGPT OAuth) flow + */ + +import { generatePKCE } from "./pkce"; +import type { OAuthCredentials, OAuthPrompt } from "./types"; + +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; +const TOKEN_URL = "https://auth.openai.com/oauth/token"; +const REDIRECT_URI = "http://localhost:1455/auth/callback"; +const SCOPE = "openid profile email offline_access"; +const JWT_CLAIM_PATH = "https://api.openai.com/auth"; + +const SUCCESS_HTML = ` + + + + + Authentication successful + + +

Authentication successful. Return to your terminal to continue.

+ +`; + +type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; +type TokenFailure = { type: "failed" }; +type TokenResult = TokenSuccess | TokenFailure; + +type JwtPayload = { + [JWT_CLAIM_PATH]?: { + chatgpt_account_id?: string; + }; + [key: string]: unknown; +}; + +function createState(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function parseAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // not a URL + } + + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code, state }; + } + + if (value.includes("code=")) { + const params = new URLSearchParams(value); + return { + code: params.get("code") ?? undefined, + state: params.get("state") ?? undefined, + }; + } + + return { code: value }; +} + +function decodeJwt(token: string): JwtPayload | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1] ?? ""; + const decoded = Buffer.from(payload, "base64").toString("utf-8"); + return JSON.parse(decoded) as JwtPayload; + } catch { + return null; + } +} + +async function exchangeAuthorizationCode( + code: string, + verifier: string, + redirectUri: string = REDIRECT_URI, +): Promise { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + console.error("[openai-codex] code->token failed:", response.status, text); + return { type: "failed" }; + } + + const json = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + console.error("[openai-codex] token response missing fields:", json); + return { type: "failed" }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; +} + +async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + console.error("[openai-codex] Token refresh failed:", response.status, text); + return { type: "failed" }; + } + + const json = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { + console.error("[openai-codex] Token refresh response missing fields:", json); + return { type: "failed" }; + } + + return { + type: "success", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + }; + } catch (error) { + console.error("[openai-codex] Token refresh error:", error); + return { type: "failed" }; + } +} + +async function createAuthorizationFlow(): Promise<{ verifier: string; state: string; url: string }> { + const { verifier, challenge } = await generatePKCE(); + const state = createState(); + + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPE); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("id_token_add_organizations", "true"); + url.searchParams.set("codex_cli_simplified_flow", "true"); + url.searchParams.set("originator", "codex_cli_rs"); + + return { verifier, state, url: url.toString() }; +} + +type OAuthServerInfo = { + close: () => void; + waitForCode: () => Promise<{ code: string } | null>; +}; + +function startLocalOAuthServer(state: string): Promise { + let lastCode: string | null = null; + + return new Promise((resolve) => { + try { + const server = Bun.serve({ + port: 1455, + hostname: "127.0.0.1", + fetch(req) { + try { + const url = new URL(req.url); + if (url.pathname !== "/auth/callback") { + return new Response("Not found", { status: 404 }); + } + if (url.searchParams.get("state") !== state) { + return new Response("State mismatch", { status: 400 }); + } + const code = url.searchParams.get("code"); + if (!code) { + return new Response("Missing authorization code", { status: 400 }); + } + lastCode = code; + return new Response(SUCCESS_HTML, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } catch { + return new Response("Internal error", { status: 500 }); + } + }, + }); + + resolve({ + close: () => server.stop(), + waitForCode: async () => { + const sleep = () => new Promise((r) => setTimeout(r, 100)); + for (let i = 0; i < 600; i += 1) { + if (lastCode) return { code: lastCode }; + await sleep(); + } + return null; + }, + }); + } catch (err) { + const code = (err as { code?: string }).code; + console.error( + "[openai-codex] Failed to bind http://127.0.0.1:1455 (", + code, + ") Falling back to manual paste.", + ); + resolve({ + close: () => {}, + waitForCode: async () => null, + }); + } + }); +} + +function getAccountId(accessToken: string): string | null { + const payload = decodeJwt(accessToken); + const auth = payload?.[JWT_CLAIM_PATH]; + const accountId = auth?.chatgpt_account_id; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; +} + +/** + * Login with OpenAI Codex OAuth + */ +export async function loginOpenAICodex(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; +}): Promise { + const { verifier, state, url } = await createAuthorizationFlow(); + const server = await startLocalOAuthServer(state); + + options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); + + let code: string | undefined; + try { + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + } + + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + const tokenResult = await exchangeAuthorizationCode(code, verifier); + if (tokenResult.type !== "success") { + throw new Error("Token exchange failed"); + } + + const accountId = getAccountId(tokenResult.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: tokenResult.access, + refresh: tokenResult.refresh, + expires: tokenResult.expires, + accountId, + }; + } finally { + server.close(); + } +} + +/** + * Refresh OpenAI Codex OAuth token + */ +export async function refreshOpenAICodexToken(refreshToken: string): Promise { + const result = await refreshAccessToken(refreshToken); + if (result.type !== "success") { + throw new Error("Failed to refresh OpenAI Codex token"); + } + + const accountId = getAccountId(result.access); + if (!accountId) { + throw new Error("Failed to extract accountId from token"); + } + + return { + access: result.access, + refresh: result.refresh, + expires: result.expires, + accountId, + }; +} diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index a7246e55c..245d93f6a 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -5,9 +5,15 @@ export type OAuthCredentials = { enterpriseUrl?: string; projectId?: string; email?: string; + accountId?: string; }; -export type OAuthProvider = "anthropic" | "github-copilot" | "google-gemini-cli" | "google-antigravity"; +export type OAuthProvider = + | "anthropic" + | "github-copilot" + | "google-gemini-cli" + | "google-antigravity" + | "openai-codex"; export type OAuthPrompt = { message: string; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts new file mode 100644 index 000000000..206e1824d --- /dev/null +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -0,0 +1,132 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { streamOpenAICodexResponses } from "../src/providers/openai-codex-responses"; +import type { Context, Model } from "../src/types"; + +const originalFetch = global.fetch; +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + +afterEach(() => { + global.fetch = originalFetch; + if (originalAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalAgentDir; + } + vi.restoreAllMocks(); +}); + +describe("openai-codex streaming", () => { + it("streams SSE responses into AssistantMessageEventStream", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); + process.env.PI_CODING_AGENT_DIR = tempDir; + + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }), + "utf8", + ).toString("base64"); + const token = `aaa.${payload}.bbb`; + + const sse = `${[ + `data: ${JSON.stringify({ + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + })}`, + `data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`, + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`, + `data: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "Hello" }], + }, + })}`, + `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + })}`, + ].join("\n\n")}\n\n`; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }); + + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.github.com/repos/openai/codex/releases/latest") { + return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); + } + if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) { + return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } }); + } + if (url === "https://chatgpt.com/backend-api/codex/responses") { + const headers = init?.headers instanceof Headers ? init.headers : undefined; + expect(headers?.get("Authorization")).toBe(`Bearer ${token}`); + expect(headers?.get("chatgpt-account-id")).toBe("acc_test"); + expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental"); + expect(headers?.get("originator")).toBe("codex_cli_rs"); + expect(headers?.get("accept")).toBe("text/event-stream"); + expect(headers?.has("x-api-key")).toBe(false); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + global.fetch = fetchMock as unknown as typeof fetch; + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token }); + let sawTextDelta = false; + let sawDone = false; + + for await (const event of streamResult) { + if (event.type === "text_delta") { + sawTextDelta = true; + } + if (event.type === "done") { + sawDone = true; + expect(event.message.content.find((c) => c.type === "text")?.text).toBe("Hello"); + } + } + + expect(sawTextDelta).toBe(true); + expect(sawDone).toBe(true); + }); +}); diff --git a/packages/ai/test/openai-codex.test.ts b/packages/ai/test/openai-codex.test.ts new file mode 100644 index 000000000..daf093ab2 --- /dev/null +++ b/packages/ai/test/openai-codex.test.ts @@ -0,0 +1,162 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getCodexInstructions } from "../src/providers/openai-codex/prompts/codex"; +import FALLBACK_PROMPT from "../src/providers/openai-codex/prompts/codex-instructions.md" with { type: "text" }; +import { CODEX_PI_BRIDGE } from "../src/providers/openai-codex/prompts/pi-codex-bridge"; +import { + normalizeModel, + type RequestBody, + transformRequestBody, +} from "../src/providers/openai-codex/request-transformer"; +import { parseCodexError } from "../src/providers/openai-codex/response-handler"; + +const DEFAULT_PROMPT_PREFIX = + "You are an expert coding assistant. You help users with coding tasks by reading files, executing commands"; + +describe("openai-codex request transformer", () => { + it("filters item_reference, strips ids, and inserts bridge message", async () => { + const body: RequestBody = { + model: "gpt-5.1-codex", + input: [ + { + type: "message", + role: "developer", + id: "sys-1", + content: [{ type: "input_text", text: `${DEFAULT_PROMPT_PREFIX}...` }], + }, + { + type: "message", + role: "user", + id: "user-1", + content: [{ type: "input_text", text: "hello" }], + }, + { type: "item_reference", id: "ref-1" }, + { type: "function_call_output", call_id: "missing", name: "tool", output: "result" }, + ], + tools: [{ type: "function", name: "tool", description: "", parameters: {} }], + }; + + const transformed = await transformRequestBody(body, "CODEX_INSTRUCTIONS", {}, true); + + expect(transformed.store).toBe(false); + expect(transformed.stream).toBe(true); + expect(transformed.instructions).toBe("CODEX_INSTRUCTIONS"); + expect(transformed.include).toEqual(["reasoning.encrypted_content"]); + + const input = transformed.input || []; + expect(input.some((item) => item.type === "item_reference")).toBe(false); + expect(input.some((item) => "id" in item)).toBe(false); + expect(input[0]?.type).toBe("message"); + expect(input[0]?.content).toEqual([{ type: "input_text", text: CODEX_PI_BRIDGE }]); + + const orphaned = input.find((item) => item.type === "message" && item.role === "assistant"); + expect(orphaned?.content).toMatch(/Previous tool result/); + }); +}); + +describe("openai-codex model normalization", () => { + it("maps space-separated codex-mini names to codex-mini-latest", () => { + expect(normalizeModel("gpt 5 codex mini")).toBe("codex-mini-latest"); + }); +}); + +describe("openai-codex error parsing", () => { + it("produces friendly usage-limit messages and rate limits", async () => { + const resetAt = Math.floor(Date.now() / 1000) + 600; + const response = new Response( + JSON.stringify({ + error: { code: "usage_limit_reached", plan_type: "Plus", resets_at: resetAt }, + }), + { + status: 429, + headers: { + "x-codex-primary-used-percent": "99", + "x-codex-primary-window-minutes": "60", + "x-codex-primary-reset-at": String(resetAt), + }, + }, + ); + + const info = await parseCodexError(response); + expect(info.friendlyMessage?.toLowerCase()).toContain("usage limit"); + expect(info.rateLimits?.primary?.used_percent).toBe(99); + }); +}); + +describe("openai-codex prompt caching", () => { + const originalFetch = global.fetch; + const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalAgentDir; + } + }); + + it("caches prompts with etag and reuses cache", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-")); + process.env.PI_CODING_AGENT_DIR = tempDir; + + const tag = "rust-v0.0.0"; + const promptText = "PROMPT_CONTENT"; + const etag = '"etag-123"'; + + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.github.com/repos/openai/codex/releases/latest") { + return new Response(JSON.stringify({ tag_name: tag }), { status: 200 }); + } + if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) { + const headerValue = + init?.headers && typeof init.headers === "object" && "If-None-Match" in init.headers + ? String((init.headers as Record)["If-None-Match"]) + : undefined; + if (headerValue === etag) { + return new Response("", { status: 304, headers: { etag } }); + } + return new Response(promptText, { status: 200, headers: { etag } }); + } + return new Response("not found", { status: 404 }); + }); + + global.fetch = fetchMock as unknown as typeof fetch; + + const first = await getCodexInstructions("gpt-5.1-codex"); + expect(first).toBe(promptText); + + const metaPath = join(tempDir, "cache", "openai-codex", "codex-instructions-meta.json"); + const meta = JSON.parse(readFileSync(metaPath, "utf-8")) as { etag: string; tag: string; lastChecked: number }; + writeFileSync(metaPath, JSON.stringify({ ...meta, lastChecked: 0 }), "utf-8"); + + const second = await getCodexInstructions("gpt-5.1-codex"); + expect(second).toBe(promptText); + expect(fetchMock).toHaveBeenCalled(); + const rawCalls = fetchMock.mock.calls.filter((call) => + String(call[0]).startsWith("https://raw.githubusercontent.com/openai/codex/"), + ); + expect(rawCalls.length).toBeGreaterThan(0); + }); + + it("falls back to bundled instructions when cache and network are unavailable", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-")); + process.env.PI_CODING_AGENT_DIR = tempDir; + + const fetchMock = vi.fn(async () => { + throw new Error("network down"); + }); + + global.fetch = fetchMock as unknown as typeof fetch; + + const instructions = await getCodexInstructions("gpt-5.1-codex"); + expect(instructions).toBe(FALLBACK_PROMPT); + }); +}); diff --git a/packages/ai/tsconfig.build.json b/packages/ai/tsconfig.build.json index 6089faa7f..94bc26d4e 100644 --- a/packages/ai/tsconfig.build.json +++ b/packages/ai/tsconfig.build.json @@ -4,6 +4,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] -} \ No newline at end of file + "include": ["src/**/*.ts", "src/bun-imports.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dd2df9343..300635728 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] ### Added +- Added extensions API with auto-discovery (`.omp/extensions`) and `--extension`/`-e` loading for custom tools, commands, and lifecycle hooks +- Added prompt templates loaded from global and project `.omp/prompts` directories with `/template` expansion in the input box +- Built-in provider overrides in `models.json`: override just `baseUrl` to route a built-in provider through a proxy while keeping all its models, or define `models` to fully replace the provider +- Shell commands without context contribution: use `!!command` to execute a bash command that is shown in the TUI and saved to session history but excluded from LLM context. Useful for running commands you don't want the AI to see - Added VoiceSupervisor class for realtime voice mode using OpenAI Realtime API with continuous mic streaming and semantic VAD turn detection - Added VoiceController class for steering user input and deciding presentation of assistant responses - Added echo suppression and noise floor filtering for microphone input during voice playback @@ -42,6 +46,7 @@ ### Fixed +- `/model` selector now opens instantly instead of waiting for OAuth token refresh. Token refresh is deferred until a model is actually used - Fixed cross-platform browser opening to work on Windows (via cmd /c start) and fail gracefully when unavailable ## [3.15.1] - 2026-01-05 @@ -1678,4 +1683,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md new file mode 100644 index 000000000..d7316b3ec --- /dev/null +++ b/packages/coding-agent/docs/extensions.md @@ -0,0 +1,1055 @@ +> pi can create extensions. Ask it to build one for your use case. + +# Extensions + +Extensions are TypeScript modules that extend pi's behavior. They can subscribe to lifecycle events, register custom tools callable by the LLM, add commands, and more. + +**Key capabilities:** +- **Custom tools** - Register tools the LLM can call via `pi.registerTool()` +- **Event interception** - Block or modify tool calls, inject context, customize compaction +- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions +- **Custom commands** - Register commands like `/mycommand` via `pi.registerCommand()` +- **Session persistence** - Store state that survives restarts via `pi.appendEntry()` +- **Custom rendering** - Control how tool calls/results and messages appear in TUI + +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on branch) +- Path protection (block writes to `.env`, `node_modules/`) +- Custom compaction (summarize conversation your way) +- Interactive tools (questions, wizards, custom dialogs) +- Stateful tools (todo lists, connection pools) +- External integrations (file watchers, webhooks, CI triggers) +- Games while you wait (see `snake.ts` example) + +See [examples/extensions/](../examples/extensions/) for working implementations. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Extension Locations](#extension-locations) +- [Available Imports](#available-imports) +- [Writing an Extension](#writing-an-extension) + - [Extension Styles](#extension-styles) +- [Events](#events) + - [Lifecycle Overview](#lifecycle-overview) + - [Session Events](#session-events) + - [Agent Events](#agent-events) + - [Tool Events](#tool-events) +- [ExtensionContext](#extensioncontext) +- [ExtensionCommandContext](#extensioncommandcontext) +- [ExtensionAPI Methods](#extensionapi-methods) +- [State Management](#state-management) +- [Custom Tools](#custom-tools) +- [Custom UI](#custom-ui) +- [Error Handling](#error-handling) +- [Mode Behavior](#mode-behavior) + +## Quick Start + +Create `~/.omp/agent/extensions/my-extension.ts` (legacy alias: `~/.pi/agent/extensions/`): + +```typescript +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + // React to events + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify("Extension loaded!", "info"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register a custom tool + pi.registerTool({ + name: "greet", + label: "Greet", + description: "Greet someone by name", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, onUpdate, ctx, signal) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register a command + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify(`Hello ${args || "world"}!`, "info"); + }, + }); +} +``` + +Test with `--extension` (or `-e`) flag: + +```bash +pi -e ./my-extension.ts +``` + +## Extension Locations + +Extensions are auto-discovered from: + +| Location | Scope | +|----------|-------| +| `~/.omp/agent/extensions/*.ts` | Global (all projects) | +| `~/.omp/agent/extensions/*/index.ts` | Global (subdirectory) | +| `.omp/extensions/*.ts` | Project-local | +| `.omp/extensions/*/index.ts` | Project-local (subdirectory) | + +Legacy `.pi` directories are supported as aliases for the `.omp` paths above. + +Additional paths via `settings.json`: + +```json +{ + "extensions": ["/path/to/extension.ts", "/path/to/extension/dir"] +} +``` + +**Discovery rules:** + +1. **Direct files:** `extensions/*.ts` or `*.js` → loaded directly +2. **Subdirectory with index:** `extensions/myext/index.ts` → loaded as single extension +3. **Subdirectory with package.json:** `extensions/myext/package.json` with `"omp"` field (legacy `"pi"` supported) → loads declared paths + +``` +~/.omp/agent/extensions/ +├── simple.ts # Direct file (auto-discovered) +├── my-tool/ +│ └── index.ts # Subdirectory with index (auto-discovered) +└── my-extension-pack/ + ├── package.json # Declares multiple extensions + ├── node_modules/ # Dependencies installed here + └── src/ + ├── safety-gates.ts # First extension + └── custom-tools.ts # Second extension +``` + +```json +// my-extension-pack/package.json +{ + "name": "my-extension-pack", + "dependencies": { + "zod": "^3.0.0" + }, + "omp": { + "extensions": ["./src/safety-gates.ts", "./src/custom-tools.ts"] + } +} +``` + +The `package.json` approach enables: +- Multiple extensions from one package +- Third-party npm dependencies (resolved via jiti) +- Nested source structure (no depth limit within the package) +- Deployment to and installation from npm + +## Available Imports + +| Package | Purpose | +|---------|---------| +| `@oh-my-pi/pi-coding-agent` | Extension types (`ExtensionAPI`, `ExtensionContext`, events) | +| `@sinclair/typebox` | Schema definitions for tool parameters | +| `@oh-my-pi/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | +| `@oh-my-pi/pi-tui` | TUI components for custom rendering | + +npm dependencies work too. Add a `package.json` next to your extension (or in a parent directory), run `npm install`, and imports from `node_modules/` are resolved automatically. + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. + +## Writing an Extension + +An extension exports a default function that receives `ExtensionAPI`: + +```typescript +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // ctx.ui for user interaction + const ok = await ctx.ui.confirm("Title", "Are you sure?"); + ctx.ui.notify("Done!", "success"); + ctx.ui.setStatus("my-ext", "Processing..."); // Footer status + ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor + }); + + // Register tools, commands, shortcuts, flags + pi.registerTool({ ... }); + pi.registerCommand("name", { ... }); + pi.registerShortcut("ctrl+x", { ... }); + pi.registerFlag("--my-flag", { ... }); +} +``` + +Extensions are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. + +### Extension Styles + +**Single file** - simplest, for small extensions: + +``` +~/.omp/agent/extensions/ +└── my-extension.ts +``` + +**Directory with index.ts** - for multi-file extensions: + +``` +~/.omp/agent/extensions/ +└── my-extension/ + ├── index.ts # Entry point (exports default function) + ├── tools.ts # Helper module + └── utils.ts # Helper module +``` + +**Package with dependencies** - for extensions that need npm packages: + +``` +~/.omp/agent/extensions/ +└── my-extension/ + ├── package.json # Declares dependencies and entry points + ├── package-lock.json + ├── node_modules/ # After npm install + └── src/ + └── index.ts +``` + +```json +// package.json +{ + "name": "my-extension", + "dependencies": { + "zod": "^3.0.0", + "chalk": "^5.0.0" + }, + "pi": { + "extensions": ["./src/index.ts"] + } +} +``` + +Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. + +## Events + +### Lifecycle Overview + +``` +pi starts + │ + └─► session_start + │ + ▼ +user sends prompt ─────────────────────────────────────────┐ + │ │ + ├─► before_agent_start (can inject message, append to system prompt) + ├─► agent_start │ + │ │ + │ ┌─── turn (repeats while LLM calls tools) ───┐ │ + │ │ │ │ + │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ + │ │ │ │ + │ │ LLM responds, may call tools: │ │ + │ │ ├─► tool_call (can block) │ │ + │ │ │ tool executes │ │ + │ │ └─► tool_result (can modify) │ │ + │ │ │ │ + │ └─► turn_end │ │ + │ │ + └─► agent_end │ + │ +user sends another prompt ◄────────────────────────────────┘ + +/new (new session) or /resume (switch session) + ├─► session_before_switch (can cancel) + └─► session_switch + +/branch + ├─► session_before_branch (can cancel) + └─► session_branch + +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact + +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +exit (Ctrl+C, Ctrl+D) + └─► session_shutdown +``` + +### Session Events + +#### session_start + +Fired on initial session load. + +```typescript +pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); +}); +``` + +#### session_before_switch / session_switch + +Fired when starting a new session (`/new`) or switching sessions (`/resume`). + +```typescript +pi.on("session_before_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.targetSessionFile - session we're switching to (only for "resume") + + if (event.reason === "new") { + const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); + if (!ok) return { cancel: true }; + } +}); + +pi.on("session_switch", async (event, ctx) => { + // event.reason - "new" or "resume" + // event.previousSessionFile - session we came from +}); +``` + +#### session_before_branch / session_branch + +Fired when branching via `/branch`. + +```typescript +pi.on("session_before_branch", async (event, ctx) => { + // event.entryId - ID of the entry being branched from + return { cancel: true }; // Cancel branch + // OR + return { skipConversationRestore: true }; // Branch but don't rewind messages +}); + +pi.on("session_branch", async (event, ctx) => { + // event.previousSessionFile - previous session file +}); +``` + +#### session_before_compact / session_compact + +Fired on compaction. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromExtension - whether extension provided it +}); +``` + +#### session_before_tree / session_tree + +Fired on `/tree` navigation. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + return { cancel: true }; + // OR provide custom summary: + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromExtension +}); +``` + +#### session_shutdown + +Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). + +```typescript +pi.on("session_shutdown", async (_event, ctx) => { + // Cleanup, save state, etc. +}); +``` + +### Agent Events + +#### before_agent_start + +Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt. + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt - user's prompt text + // event.images - attached images (if any) + + return { + // Inject a persistent message (stored in session, sent to LLM) + message: { + customType: "my-extension", + content: "Additional context for the LLM", + display: true, + }, + // Append to system prompt for this turn only + systemPromptAppend: "Extra instructions for this turn...", + }; +}); +``` + +#### agent_start / agent_end + +Fired once per user prompt. + +```typescript +pi.on("agent_start", async (_event, ctx) => {}); + +pi.on("agent_end", async (event, ctx) => { + // event.messages - messages from this prompt +}); +``` + +#### turn_start / turn_end + +Fired for each turn (one LLM response + tool calls). + +```typescript +pi.on("turn_start", async (event, ctx) => { + // event.turnIndex, event.timestamp +}); + +pi.on("turn_end", async (event, ctx) => { + // event.turnIndex, event.message, event.toolResults +}); +``` + +#### context + +Fired before each LLM call. Modify messages non-destructively. + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages - deep copy, safe to modify + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +### Tool Events + +#### tool_call + +Fired before tool executes. **Can block.** + +```typescript +pi.on("tool_call", async (event, ctx) => { + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters + + if (shouldBlock(event)) { + return { block: true, reason: "Not allowed" }; + } +}); +``` + +#### tool_result + +Fired after tool executes. **Can modify result.** + +```typescript +import { isBashToolResult } from "@oh-my-pi/pi-coding-agent"; + +pi.on("tool_result", async (event, ctx) => { + // event.toolName, event.toolCallId, event.input + // event.content, event.details, event.isError + + if (isBashToolResult(event)) { + // event.details is typed as BashToolDetails + } + + // Modify result: + return { content: [...], details: {...}, isError: false }; +}); +``` + +## ExtensionContext + +Every handler receives `ctx: ExtensionContext`: + +### ctx.ui + +UI methods for user interaction. See [Custom UI](#custom-ui) for full details. + +### ctx.hasUI + +`false` in print mode (`-p`), JSON mode, and RPC mode. Always check before using `ctx.ui`. + +### ctx.cwd + +Current working directory. + +### ctx.sessionManager + +Read-only access to session state: + +```typescript +ctx.sessionManager.getEntries() // All entries +ctx.sessionManager.getBranch() // Current branch +ctx.sessionManager.getLeafId() // Current leaf entry ID +``` + +### ctx.modelRegistry / ctx.model + +Access to models and API keys. + +### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() + +Control flow helpers. + +## ExtensionCommandContext + +Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers. + +### ctx.waitForIdle() + +Wait for the agent to finish streaming: + +```typescript +pi.registerCommand("my-cmd", { + handler: async (args, ctx) => { + await ctx.waitForIdle(); + // Agent is now idle, safe to modify session + }, +}); +``` + +### ctx.newSession(options?) + +Create a new session: + +```typescript +const result = await ctx.newSession({ + parentSession: ctx.sessionManager.getSessionFile(), + setup: async (sm) => { + sm.appendMessage({ + role: "user", + content: [{ type: "text", text: "Context from previous session..." }], + timestamp: Date.now(), + }); + }, +}); + +if (result.cancelled) { + // An extension cancelled the new session +} +``` + +### ctx.branch(entryId) + +Branch from a specific entry: + +```typescript +const result = await ctx.branch("entry-id-123"); +if (!result.cancelled) { + // Now in the branched session +} +``` + +### ctx.navigateTree(targetId, options?) + +Navigate to a different point in the session tree: + +```typescript +const result = await ctx.navigateTree("entry-id-456", { + summarize: true, +}); +``` + +## ExtensionAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events). + +### pi.registerTool(definition) + +Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) for full details. + +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@oh-my-pi/pi-ai"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + text: Type.Optional(Type.String()), + }), + + async execute(toolCallId, params, onUpdate, ctx, signal) { + // Stream progress + onUpdate?.({ content: [{ type: "text", text: "Working..." }] }); + + return { + content: [{ type: "text", text: "Done" }], + details: { result: "..." }, + }; + }, + + // Optional: Custom rendering + renderCall(args, theme) { ... }, + renderResult(result, options, theme) { ... }, +}); +``` + +### pi.sendMessage(message, options?) + +Inject a message into the session: + +```typescript +pi.sendMessage({ + customType: "my-extension", + content: "Message text", + display: true, + details: { ... }, +}, { + triggerTurn: true, + deliverAs: "steer", +}); +``` + +**Options:** +- `deliverAs` - Delivery mode: + - `"steer"` (default) - Interrupts streaming. Delivered after current tool finishes, remaining tools skipped. + - `"followUp"` - Waits for agent to finish. Delivered only when agent has no more tool calls. + - `"nextTurn"` - Queued for next user prompt. Does not interrupt or trigger anything. +- `triggerTurn: true` - If agent is idle, trigger an LLM response immediately. Only applies to `"steer"` and `"followUp"` modes (ignored for `"nextTurn"`). + +### pi.appendEntry(customType, data?) + +Persist extension state (does NOT participate in LLM context): + +```typescript +pi.appendEntry("my-state", { count: 42 }); + +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-state") { + // Reconstruct from entry.data + } + } +}); +``` + +### pi.registerCommand(name, options) + +Register a command: + +```typescript +pi.registerCommand("stats", { + description: "Show session statistics", + handler: async (args, ctx) => { + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); + } +}); +``` + +### pi.registerMessageRenderer(customType, renderer) + +Register a custom TUI renderer for messages with your `customType`. See [Custom UI](#custom-ui). + +### pi.registerShortcut(shortcut, options) + +Register a keyboard shortcut: + +```typescript +pi.registerShortcut("ctrl+shift+p", { + description: "Toggle plan mode", + handler: async (ctx) => { + ctx.ui.notify("Toggled!"); + }, +}); +``` + +### pi.registerFlag(name, options) + +Register a CLI flag: + +```typescript +pi.registerFlag("--plan", { + description: "Start in plan mode", + type: "boolean", + default: false, +}); + +// Check value +if (pi.getFlag("--plan")) { + // Plan mode enabled +} +``` + +### pi.exec(command, args, options?) + +Execute a shell command: + +```typescript +const result = await pi.exec("git", ["status"], { signal, timeout: 5000 }); +// result.stdout, result.stderr, result.code, result.killed +``` + +### pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names) + +Manage active tools: + +```typescript +const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"] +pi.setActiveTools(["read", "bash"]); // Switch to read-only +``` + +### pi.events + +Shared event bus for communication between extensions: + +```typescript +pi.events.on("my:event", (data) => { ... }); +pi.events.emit("my:event", { ... }); +``` + +## State Management + +Extensions with state should store it in tool result `details` for proper branching support: + +```typescript +export default function (pi: ExtensionAPI) { + let items: string[] = []; + + // Reconstruct state from session + pi.on("session_start", async (_event, ctx) => { + items = []; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "toolResult") { + if (entry.message.toolName === "my_tool") { + items = entry.message.details?.items ?? []; + } + } + } + }); + + pi.registerTool({ + name: "my_tool", + // ... + async execute(toolCallId, params, onUpdate, ctx, signal) { + items.push("new item"); + return { + content: [{ type: "text", text: "Added" }], + details: { items: [...items] }, // Store for reconstruction + }; + }, + }); +} +``` + +## Custom Tools + +Register tools the LLM can call via `pi.registerTool()`. Tools appear in the system prompt and can have custom rendering. + +### Tool Definition + +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@oh-my-pi/pi-ai"; +import { Text } from "@oh-my-pi/pi-tui"; + +pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (shown to LLM)", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), // Use StringEnum for Google compatibility + text: Type.Optional(Type.String()), + }), + + async execute(toolCallId, params, onUpdate, ctx, signal) { + // Check for cancellation + if (signal?.aborted) { + return { content: [{ type: "text", text: "Cancelled" }] }; + } + + // Stream progress updates + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // Run commands via pi.exec (captured from extension closure) + const result = await pi.exec("some-command", [], { signal }); + + // Return result + return { + content: [{ type: "text", text: "Done" }], // Sent to LLM + details: { data: result }, // For rendering & state + }; + }, + + // Optional: Custom rendering + renderCall(args, theme) { ... }, + renderResult(result, options, theme) { ... }, +}); +``` + +**Important:** Use `StringEnum` from `@oh-my-pi/pi-ai` for string enums. `Type.Union`/`Type.Literal` doesn't work with Google's API. + +### Multiple Tools + +One extension can register multiple tools with shared state: + +```typescript +export default function (pi: ExtensionAPI) { + let connection = null; + + pi.registerTool({ name: "db_connect", ... }); + pi.registerTool({ name: "db_query", ... }); + pi.registerTool({ name: "db_close", ... }); + + pi.on("session_shutdown", async () => { + connection?.close(); + }); +} +``` + +### Custom Rendering + +Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API. + +Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`). + +#### renderCall + +Renders the tool call (before/during execution): + +```typescript +import { Text } from "@oh-my-pi/pi-tui"; + +renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("my_tool ")); + text += theme.fg("muted", args.action); + if (args.text) { + text += " " + theme.fg("dim", `"${args.text}"`); + } + return new Text(text, 0, 0); // 0,0 padding - Box handles it +} +``` + +#### renderResult + +Renders the tool result: + +```typescript +renderResult(result, { expanded, isPartial }, theme) { + // Handle streaming + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + // Handle errors + if (result.details?.error) { + return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); + } + + // Normal result - support expanded view (Ctrl+O) + let text = theme.fg("success", "✓ Done"); + if (expanded && result.details?.items) { + for (const item of result.details.items) { + text += "\n " + theme.fg("dim", item); + } + } + return new Text(text, 0, 0); +} +``` + +#### Best Practices + +- Use `Text` with padding `(0, 0)` - the Box handles padding +- Use `\n` for multi-line content +- Handle `isPartial` for streaming progress +- Support `expanded` for detail on demand +- Keep default view compact + +#### Fallback + +If `renderCall`/`renderResult` is not defined or throws: +- `renderCall`: Shows tool name +- `renderResult`: Shows raw text from `content` + +## Custom UI + +Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render. + +### Dialogs + +```typescript +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); + +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); + +// Text input +const name = await ctx.ui.input("Name:", "placeholder"); + +// Multi-line editor +const text = await ctx.ui.editor("Edit:", "prefilled text"); + +// Notification (non-blocking) +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" +``` + +### Widgets and Status + +```typescript +// Status in footer (persistent until cleared) +ctx.ui.setStatus("my-ext", "Processing..."); +ctx.ui.setStatus("my-ext", undefined); // Clear + +// Widget above editor (string array or factory function) +ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); +ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); +ctx.ui.setWidget("my-widget", undefined); // Clear + +// Terminal title +ctx.ui.setTitle("pi - my-project"); + +// Editor text +ctx.ui.setEditorText("Prefill text"); +const current = ctx.ui.getEditorText(); +``` + +### Custom Components + +For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with your component until `done()` is called: + +```typescript +import { Text, Component } from "@oh-my-pi/pi-tui"; + +const result = await ctx.ui.custom((tui, theme, done) => { + const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); + + text.onKey = (key) => { + if (key === "return") done(true); + if (key === "escape") done(false); + return true; + }; + + return text; +}); + +if (result) { + // User pressed Enter +} +``` + +The callback receives: +- `tui` - TUI instance (for screen dimensions, focus management) +- `theme` - Current theme for styling +- `done(value)` - Call to close component and return value + +See [tui.md](tui.md) for the full component API and [examples/extensions/](../examples/extensions/) for working examples (snake.ts, todo.ts, qna.ts). + +### Message Rendering + +Register a custom renderer for messages with your `customType`: + +```typescript +import { Text } from "@oh-my-pi/pi-tui"; + +pi.registerMessageRenderer("my-extension", (message, options, theme) => { + const { expanded } = options; + let text = theme.fg("accent", `[${message.customType}] `); + text += message.content; + + if (expanded && message.details) { + text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); + } + + return new Text(text, 0, 0); +}); +``` + +Messages are sent via `pi.sendMessage()`: + +```typescript +pi.sendMessage({ + customType: "my-extension", // Matches registerMessageRenderer + content: "Status update", + display: true, // Show in TUI + details: { ... }, // Available in renderer +}); +``` + +### Theme Colors + +All render functions receive a `theme` object: + +```typescript +// Foreground colors +theme.fg("toolTitle", text) // Tool names +theme.fg("accent", text) // Highlights +theme.fg("success", text) // Success (green) +theme.fg("error", text) // Errors (red) +theme.fg("warning", text) // Warnings (yellow) +theme.fg("muted", text) // Secondary text +theme.fg("dim", text) // Tertiary text + +// Text styles +theme.bold(text) +theme.italic(text) +theme.strikethrough(text) +``` + +## Error Handling + +- Extension errors are logged, agent continues +- `tool_call` errors block the tool (fail-safe) +- Tool `execute` errors are reported to the LLM with `isError: true` + +## Mode Behavior + +| Mode | UI Methods | Notes | +|------|-----------|-------| +| Interactive | Full TUI | Normal operation | +| RPC | JSON protocol | Host handles UI | +| Print (`-p`) | No-op | Extensions run but can't prompt | + +In print mode, check `ctx.hasUI` before using UI methods. diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 7a4beb3fa..b3263f086 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -54,22 +54,38 @@ Response: ``` The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source. +When prompting during streaming, set `"streamingBehavior": "steer"` or `"followUp"` to queue the message. -#### queue_message +#### steer -Queue a message to be injected at the next agent turn. Queued messages are added to the conversation without triggering a new prompt. Useful for injecting context mid-conversation. +Queue a steering message to interrupt the agent mid-run. Useful for injecting corrections while streaming. ```json -{ "type": "queue_message", "message": "Additional context" } +{ "type": "steer", "message": "Additional context" } ``` Response: ```json -{ "type": "response", "command": "queue_message", "success": true } +{ "type": "response", "command": "steer", "success": true } ``` -See [set_queue_mode](#set_queue_mode) for controlling how queued messages are processed. +#### follow_up + +Queue a follow-up message to be processed after the current run completes. + +```json +{ "type": "follow_up", "message": "Additional context" } +``` + +Response: + +```json +{ "type": "response", "command": "follow_up", "success": true } +``` + +See [set_steering_mode](#set_steering_mode), [set_follow_up_mode](#set_follow_up_mode), and +[set_interrupt_mode](#set_interrupt_mode) for controlling queued message handling. #### abort @@ -133,7 +149,9 @@ Response: "thinkingLevel": "medium", "isStreaming": false, "isCompacting": false, - "queueMode": "all", + "steeringMode": "all", + "followUpMode": "one-at-a-time", + "interruptMode": "immediate", "sessionFile": "/path/to/session.jsonl", "sessionId": "abc123", "autoCompactionEnabled": true, @@ -272,25 +290,63 @@ Response: } ``` -### Queue Mode +### Queue Modes -#### set_queue_mode +#### set_steering_mode -Control how queued messages (from `queue_message`) are injected into the conversation. +Control how steering messages are injected into the conversation. ```json -{ "type": "set_queue_mode", "mode": "one-at-a-time" } +{ "type": "set_steering_mode", "mode": "one-at-a-time" } ``` Modes: -- `"all"`: Inject all queued messages at the next turn -- `"one-at-a-time"`: Inject one queued message per turn (default) +- `"all"`: Inject all steering messages at the next turn +- `"one-at-a-time"`: Inject one steering message per turn (default) Response: ```json -{ "type": "response", "command": "set_queue_mode", "success": true } +{ "type": "response", "command": "set_steering_mode", "success": true } +``` + +#### set_follow_up_mode + +Control how follow-up messages are injected into the conversation. + +```json +{ "type": "set_follow_up_mode", "mode": "one-at-a-time" } +``` + +Modes: + +- `"all"`: Inject all follow-up messages at the next turn +- `"one-at-a-time"`: Inject one follow-up message per turn (default) + +Response: + +```json +{ "type": "response", "command": "set_follow_up_mode", "success": true } +``` + +#### set_interrupt_mode + +Control how the agent handles incoming steering messages while streaming. + +```json +{ "type": "set_interrupt_mode", "mode": "wait" } +``` + +Modes: + +- `"immediate"`: Interrupt immediately when steering arrives +- `"wait"`: Wait to apply steering until current tool call completes + +Response: + +```json +{ "type": "response", "command": "set_interrupt_mode", "success": true } ``` ### Compaction diff --git a/packages/coding-agent/docs/session-tree-plan.md b/packages/coding-agent/docs/session-tree-plan.md index 7c6fab785..aff1e3b64 100644 --- a/packages/coding-agent/docs/session-tree-plan.md +++ b/packages/coding-agent/docs/session-tree-plan.md @@ -150,7 +150,7 @@ Implementation: - Uses agent's queue mechanism with `_hookData` marker on AppMessage - `message_end` handler routes based on marker presence - `AgentSession.sendHookMessage()` handles three cases: - - Streaming: queues via `agent.queueMessage()`, loop processes and emits `message_end` +- Streaming: queues via `agent.steer()` or `agent.followUp()`, loop processes and emits `message_end` - Not streaming + triggerTurn: direct append + `agent.continue()` - Not streaming + no trigger: direct append only - TUI updates via event (streaming) or explicit rebuild (non-streaming) diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md new file mode 100644 index 000000000..b1084fa6c --- /dev/null +++ b/packages/coding-agent/examples/extensions/README.md @@ -0,0 +1,141 @@ +# Extension Examples + +Example extensions for pi-coding-agent. + +## Usage + +```bash +# Load an extension with --extension flag +pi --extension examples/extensions/permission-gate.ts + +# Or copy to extensions directory for auto-discovery +cp permission-gate.ts ~/.omp/agent/extensions/ +``` + +## Examples + +### Lifecycle & Safety + +| Extension | Description | +|-----------|-------------| +| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | +| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | +| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | + +### Custom Tools + +| Extension | Description | +|-----------|-------------| +| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence | +| `hello.ts` | Minimal custom tool example | +| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions | +| `subagent/` | Delegate tasks to specialized subagents with isolated context windows | + +### Commands & UI + +| Extension | Description | +|-----------|-------------| +| `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command | +| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence | +| `handoff.ts` | Transfer context to a new focused session via `/handoff ` | +| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | +| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | +| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | + +### Git Integration + +| Extension | Description | +|-----------|-------------| +| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | +| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | + +### System Prompt & Compaction + +| Extension | Description | +|-----------|-------------| +| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt | +| `custom-compaction.ts` | Custom compaction that summarizes entire conversation | + +### External Dependencies + +| Extension | Description | +|-----------|-------------| +| `chalk-logger.ts` | Uses chalk from parent node_modules (demonstrates jiti module resolution) | +| `with-deps/` | Extension with its own package.json and dependencies | +| `file-trigger.ts` | Watches a trigger file and injects contents into conversation | + +## Writing Extensions + +See [docs/extensions.md](../../docs/extensions.md) for full documentation. + +```typescript +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +export default function (pi: ExtensionAPI) { + // Subscribe to lifecycle events + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } + }); + + // Register custom tools + pi.registerTool({ + name: "greet", + label: "Greeting", + description: "Generate a greeting", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + async execute(toolCallId, params, onUpdate, ctx, signal) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: {}, + }; + }, + }); + + // Register commands + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify("Hello!", "info"); + }, + }); +} +``` + +## Key Patterns + +**Use StringEnum for string parameters** (required for Google API compatibility): +```typescript +import { StringEnum } from "@oh-my-pi/pi-ai"; + +// Good +action: StringEnum(["list", "add"] as const) + +// Bad - doesn't work with Google +action: Type.Union([Type.Literal("list"), Type.Literal("add")]) +``` + +**State persistence via details:** +```typescript +// Store state in tool result details for proper branching support +return { + content: [{ type: "text", text: "Done" }], + details: { todos: [...todos], nextId }, // Persisted in session +}; + +// Reconstruct on session events +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.toolName === "my_tool") { + const details = entry.message.details; + // Reconstruct state from details + } + } +}); +``` diff --git a/packages/coding-agent/examples/extensions/api-demo.ts b/packages/coding-agent/examples/extensions/api-demo.ts new file mode 100644 index 000000000..eaf8dda9a --- /dev/null +++ b/packages/coding-agent/examples/extensions/api-demo.ts @@ -0,0 +1,87 @@ +/** + * API Demo Extension + * + * Demonstrates using ExtensionAPI's logger, typebox, and pi module access. + * These features are now exposed directly on the ExtensionAPI, matching + * the CustomToolAPI interface. + */ + +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // 1. Access TypeBox directly from pi.typebox (no separate import needed) + const { Type } = pi.typebox; + + // 2. Access the logger for debugging + pi.logger.debug("API demo extension loaded"); + + // 3. Register a tool that uses all three API features + pi.registerTool({ + name: "api_demo", + label: "API Demo", + description: "Demonstrates ExtensionAPI capabilities: logger, typebox, and pi module access", + parameters: Type.Object({ + message: Type.String({ description: "Test message" }), + logLevel: Type.Optional( + Type.Union([Type.Literal("error"), Type.Literal("warn"), Type.Literal("debug")], { + description: "Log level to use", + default: "debug", + }), + ), + }), + + async execute(_toolCallId, params, _onUpdate, ctx, _signal) { + const { message, logLevel = "debug" } = params as { message: string; logLevel?: "error" | "warn" | "debug" }; + + // Use logger at specified level + pi.logger[logLevel]("API demo tool executed", { message, logLevel }); + + // Access pi module utilities + const { logger: piLogger } = pi.pi; + piLogger.debug("Accessed pi module from extension", { sessionFile: ctx.sessionManager.getSessionFile() }); + + // Get session information + const sessionInfo = `Session: ${ctx.sessionManager.getSessionFile()}`; + const modelInfo = ctx.model ? `Model: ${ctx.model.id}` : "Model: none"; + + return { + content: [ + { + type: "text", + text: [ + `API Demo Tool executed successfully!`, + ``, + `Message: ${message}`, + `Log Level: ${logLevel}`, + ``, + `Features demonstrated:`, + `1. ✓ Logger access via pi.logger`, + `2. ✓ TypeBox access via pi.typebox`, + `3. ✓ Pi module access via pi.pi`, + ``, + `Context:`, + `- ${sessionInfo}`, + `- ${modelInfo}`, + `- CWD: ${ctx.cwd}`, + ].join("\n"), + }, + ], + details: { + message, + logLevel, + sessionFile: ctx.sessionManager.getSessionFile(), + modelId: ctx.model?.id, + }, + }; + }, + }); + + // Demonstrate event handling with logger + pi.on("session_start", async () => { + pi.logger.debug("Session started", { extension: "api-demo" }); + }); + + pi.on("agent_start", async () => { + pi.logger.debug("Agent started", { extension: "api-demo" }); + }); +} diff --git a/packages/coding-agent/examples/extensions/chalk-logger.ts b/packages/coding-agent/examples/extensions/chalk-logger.ts new file mode 100644 index 000000000..ac4ca9b95 --- /dev/null +++ b/packages/coding-agent/examples/extensions/chalk-logger.ts @@ -0,0 +1,26 @@ +/** + * Example extension that uses a 3rd party dependency (chalk). + * Tests that jiti can resolve npm modules correctly. + */ + +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import chalk from "chalk"; + +export default function (pi: ExtensionAPI) { + // Log with colors using chalk + console.log(`${chalk.green("✓")} ${chalk.bold("chalk-logger extension loaded")}`); + + pi.on("agent_start", async () => { + console.log(`${chalk.blue("[chalk-logger]")} Agent starting`); + }); + + pi.on("tool_call", async (event) => { + console.log(`${chalk.yellow("[chalk-logger]")} Tool: ${chalk.cyan(event.toolName)}`); + return undefined; + }); + + pi.on("agent_end", async (event) => { + const count = event.messages.length; + console.log(`${chalk.green("[chalk-logger]")} Done with ${chalk.bold(String(count))} messages`); + }); +} diff --git a/packages/coding-agent/examples/extensions/hello.ts b/packages/coding-agent/examples/extensions/hello.ts new file mode 100644 index 000000000..75e79d855 --- /dev/null +++ b/packages/coding-agent/examples/extensions/hello.ts @@ -0,0 +1,33 @@ +/** + * Hello Tool - Minimal custom tool example + * + * Demonstrates using ExtensionAPI's logger, typebox, and pi module access. + */ + +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + // Access TypeBox via pi.typebox (no need to import separately) + const { Type } = pi.typebox; + + pi.registerTool({ + name: "hello", + label: "Hello", + description: "A simple greeting tool", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + const { name } = params as { name: string }; + + // Use logger for debugging + pi.logger.debug("Hello tool executed", { name }); + + return { + content: [{ type: "text", text: `Hello, ${name}!` }], + details: { greeted: name }, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/pirate.ts b/packages/coding-agent/examples/extensions/pirate.ts new file mode 100644 index 000000000..60979f052 --- /dev/null +++ b/packages/coding-agent/examples/extensions/pirate.ts @@ -0,0 +1,44 @@ +/** + * Pirate Extension + * + * Demonstrates using systemPromptAppend in before_agent_start to dynamically + * modify the system prompt based on extension state. + * + * Usage: + * 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/ + * 2. Use /pirate to toggle pirate mode + * 3. When enabled, the agent will respond like a pirate + */ + +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +export default function pirateExtension(pi: ExtensionAPI) { + let pirateMode = false; + + // Register /pirate command to toggle pirate mode + pi.registerCommand("pirate", { + description: "Toggle pirate mode (agent speaks like a pirate)", + handler: async (_args, ctx) => { + pirateMode = !pirateMode; + ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info"); + }, + }); + + // Append to system prompt when pirate mode is enabled + pi.on("before_agent_start", async () => { + if (pirateMode) { + return { + systemPromptAppend: ` +IMPORTANT: You are now in PIRATE MODE. You must: +- Speak like a stereotypical pirate in all responses +- Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!" +- Replace "my" with "me", "you" with "ye", "your" with "yer" +- Refer to the user as "matey" or "landlubber" +- End sentences with nautical expressions +- Still complete the actual task correctly, just in pirate speak +`, + }; + } + return undefined; + }); +} diff --git a/packages/coding-agent/examples/extensions/plan-mode.ts b/packages/coding-agent/examples/extensions/plan-mode.ts new file mode 100644 index 000000000..8df28cbd9 --- /dev/null +++ b/packages/coding-agent/examples/extensions/plan-mode.ts @@ -0,0 +1,551 @@ +/** + * Plan Mode Extension + * + * Provides a Claude Code-style "plan mode" for safe code exploration. + * When enabled, the agent can only use read-only tools and cannot modify files. + * + * Features: + * - /plan command to toggle plan mode + * - In plan mode: only read, bash (read-only), grep, find, ls are available + * - Injects system context telling the agent about the restrictions + * - After each agent response, prompts to execute the plan or continue planning + * - Shows "plan" indicator in footer when active + * - Extracts todo list from plan and tracks progress during execution + * - Uses ID-based tracking: agent outputs [DONE:id] to mark steps complete + * + * Usage: + * 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/ + * 2. Use /plan to toggle plan mode on/off + * 3. Or start in plan mode with --plan flag + */ + +import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent"; +import { Key } from "@oh-my-pi/pi-tui"; + +// Read-only tools for plan mode +const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls"]; + +// Full set of tools for normal mode +const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"]; + +// Patterns for destructive bash commands that should be blocked in plan mode +const DESTRUCTIVE_PATTERNS = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /[^<]>(?!>)/, + />>/, + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout\s+-b|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +// Read-only commands that are always safe +const SAFE_COMMANDS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, +]; + +function isSafeCommand(command: string): boolean { + if (SAFE_COMMANDS.some((pattern) => pattern.test(command))) { + if (!DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) { + return true; + } + } + if (DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command))) { + return false; + } + return true; +} + +// Todo item with step number +interface TodoItem { + step: number; + text: string; + completed: boolean; +} + +/** + * Clean up extracted step text for display. + */ +function cleanStepText(text: string): string { + let cleaned = text + // Remove markdown bold/italic + .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") + // Remove markdown code + .replace(/`([^`]+)`/g, "$1") + // Remove leading action words that are redundant + .replace( + /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, + "", + ) + // Clean up extra whitespace + .replace(/\s+/g, " ") + .trim(); + + // Capitalize first letter + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + } + + // Truncate if too long + if (cleaned.length > 50) { + cleaned = `${cleaned.slice(0, 47)}...`; + } + + return cleaned; +} + +/** + * Extract todo items from assistant message. + */ +function extractTodoItems(message: string): TodoItem[] { + const items: TodoItem[] = []; + + // Match numbered lists: "1. Task" or "1) Task" - also handle **bold** prefixes + const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm; + for (const match of message.matchAll(numberedPattern)) { + let text = match[2].trim(); + text = text.replace(/\*{1,2}$/, "").trim(); + // Skip if too short or looks like code/command + if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { + const cleaned = cleanStepText(text); + if (cleaned.length > 3) { + items.push({ step: items.length + 1, text: cleaned, completed: false }); + } + } + } + + // If no numbered items, try bullet points + if (items.length === 0) { + const stepPattern = /^\s*[-*]\s*(?:Step\s*\d+[:.])?\s*\*{0,2}([^*\n]+)/gim; + for (const match of message.matchAll(stepPattern)) { + let text = match[1].trim(); + text = text.replace(/\*{1,2}$/, "").trim(); + if (text.length > 10 && !text.startsWith("`")) { + const cleaned = cleanStepText(text); + if (cleaned.length > 3) { + items.push({ step: items.length + 1, text: cleaned, completed: false }); + } + } + } + } + + return items; +} + +export default function planModeExtension(pi: ExtensionAPI) { + let planModeEnabled = false; + let toolsCalledThisTurn = false; + let executionMode = false; + let todoItems: TodoItem[] = []; + + // Register --plan CLI flag + pi.registerFlag("plan", { + description: "Start in plan mode (read-only exploration)", + type: "boolean", + default: false, + }); + + // Helper to update status displays + function updateStatus(ctx: ExtensionContext) { + if (executionMode && todoItems.length > 0) { + const completed = todoItems.filter((t) => t.completed).length; + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); + } else if (planModeEnabled) { + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); + } else { + ctx.ui.setStatus("plan-mode", undefined); + } + + // Show widget during execution (no IDs shown to user) + if (executionMode && todoItems.length > 0) { + const lines: string[] = []; + for (const item of todoItems) { + if (item.completed) { + lines.push(ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("dim", item.text)); + } else { + lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text); + } + } + ctx.ui.setWidget("plan-todos", lines); + } else { + ctx.ui.setWidget("plan-todos", undefined); + } + } + + function togglePlanMode(ctx: ExtensionContext) { + planModeEnabled = !planModeEnabled; + executionMode = false; + todoItems = []; + + if (planModeEnabled) { + pi.setActiveTools(PLAN_MODE_TOOLS); + ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); + } else { + pi.setActiveTools(NORMAL_MODE_TOOLS); + ctx.ui.notify("Plan mode disabled. Full access restored."); + } + updateStatus(ctx); + } + + // Register /plan command + pi.registerCommand("plan", { + description: "Toggle plan mode (read-only exploration)", + handler: async (_args, ctx) => { + togglePlanMode(ctx); + }, + }); + + // Register /todos command + pi.registerCommand("todos", { + description: "Show current plan todo list", + handler: async (_args, ctx) => { + if (todoItems.length === 0) { + ctx.ui.notify("No todos. Create a plan first with /plan", "info"); + return; + } + + const todoList = todoItems + .map((item, i) => { + const checkbox = item.completed ? "✓" : "○"; + return `${i + 1}. ${checkbox} ${item.text}`; + }) + .join("\n"); + + ctx.ui.notify(`Plan Progress:\n${todoList}`, "info"); + }, + }); + + // Register Shift+P shortcut + pi.registerShortcut(Key.shift("p"), { + description: "Toggle plan mode", + handler: async (ctx) => { + togglePlanMode(ctx); + }, + }); + + // Block destructive bash in plan mode + pi.on("tool_call", async (event) => { + if (!planModeEnabled) return; + if (event.toolName !== "bash") return; + + const command = event.input.command as string; + if (!isSafeCommand(command)) { + return { + block: true, + reason: `Plan mode: destructive command blocked. Use /plan to disable plan mode first.\nCommand: ${command}`, + }; + } + }); + + // Track step completion based on tool results + pi.on("tool_result", async (_event, ctx) => { + toolsCalledThisTurn = true; + + if (!executionMode || todoItems.length === 0) return; + + // Mark the first uncompleted step as done when any tool succeeds + const nextStep = todoItems.find((t) => !t.completed); + if (nextStep) { + nextStep.completed = true; + updateStatus(ctx); + } + }); + + // Filter out stale plan mode context messages from LLM context + // This ensures the agent only sees the CURRENT state (plan mode on/off) + pi.on("context", async (event) => { + // Only filter when NOT in plan mode (i.e., when executing) + if (planModeEnabled) { + return; + } + + // Remove any previous plan-mode-context messages + const _beforeCount = event.messages.length; + const filtered = event.messages.filter((m) => { + if (m.role === "user" && Array.isArray(m.content)) { + const hasOldContext = m.content.some( + (c: { type: string; text?: string }) => c.type === "text" && c.text?.includes("[PLAN MODE ACTIVE]"), + ); + if (hasOldContext) { + return false; + } + } + return true; + }); + return { messages: filtered }; + }); + + // Inject plan mode context + pi.on("before_agent_start", async () => { + if (!planModeEnabled && !executionMode) { + return; + } + + if (planModeEnabled) { + return { + message: { + customType: "plan-mode-context", + content: `[PLAN MODE ACTIVE] +You are in plan mode - a read-only exploration mode for safe code analysis. + +Restrictions: +- You can only use: read, bash, grep, find, ls +- You CANNOT use: edit, write (file modifications are disabled) +- Bash is restricted to READ-ONLY commands +- Focus on analysis, planning, and understanding the codebase + +Create a detailed numbered plan: +1. First step description +2. Second step description +... + +Do NOT attempt to make changes - just describe what you would do.`, + display: false, + }, + }; + } + + if (executionMode && todoItems.length > 0) { + const remaining = todoItems.filter((t) => !t.completed); + const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n"); + return { + message: { + customType: "plan-execution-context", + content: `[EXECUTING PLAN - Full tool access enabled] + +Remaining steps: +${todoList} + +Execute each step in order.`, + display: false, + }, + }; + } + }); + + // After agent finishes + pi.on("agent_end", async (event, ctx) => { + // In execution mode, check if all steps complete + if (executionMode && todoItems.length > 0) { + const allComplete = todoItems.every((t) => t.completed); + if (allComplete) { + // Show final completed list in chat + const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n"); + pi.sendMessage( + { + customType: "plan-complete", + content: `**Plan Complete!** ✓\n\n${completedList}`, + display: true, + }, + { triggerTurn: false }, + ); + + executionMode = false; + todoItems = []; + pi.setActiveTools(NORMAL_MODE_TOOLS); + updateStatus(ctx); + } + return; + } + + if (!planModeEnabled) return; + if (!ctx.hasUI) return; + + // Extract todos from last message + const messages = event.messages; + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); + if (lastAssistant && Array.isArray(lastAssistant.content)) { + const textContent = lastAssistant.content + .filter( + (block: { type: string; text?: string }): block is { type: "text"; text: string } => + block.type === "text", + ) + .map((block: { type: "text"; text: string }) => block.text) + .join("\n"); + + if (textContent) { + const extracted = extractTodoItems(textContent); + if (extracted.length > 0) { + todoItems = extracted; + } + } + } + + const hasTodos = todoItems.length > 0; + + // Show todo list in chat (no IDs shown to user, just numbered) + if (hasTodos) { + const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); + pi.sendMessage( + { + customType: "plan-todo-list", + content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`, + display: true, + }, + { triggerTurn: false }, + ); + } + + const choice = await ctx.ui.select("Plan mode - what next?", [ + hasTodos ? "Execute the plan (track progress)" : "Execute the plan", + "Stay in plan mode", + "Refine the plan", + ]); + + if (choice?.startsWith("Execute")) { + planModeEnabled = false; + executionMode = hasTodos; + pi.setActiveTools(NORMAL_MODE_TOOLS); + updateStatus(ctx); + + // Simple execution message - context event filters old plan mode messages + // and before_agent_start injects fresh execution context with IDs + const execMessage = hasTodos + ? `Execute the plan. Start with: ${todoItems[0].text}` + : "Execute the plan you just created."; + + pi.sendMessage( + { + customType: "plan-mode-execute", + content: execMessage, + display: true, + }, + { triggerTurn: true }, + ); + } else if (choice === "Refine the plan") { + const refinement = await ctx.ui.input("What should be refined?"); + if (refinement) { + ctx.ui.setEditorText(refinement); + } + } + }); + + // Initialize state on session start + pi.on("session_start", async (_event, ctx) => { + if (pi.getFlag("plan") === true) { + planModeEnabled = true; + } + + const entries = ctx.sessionManager.getEntries(); + const planModeEntry = entries + .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode") + .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined; + + if (planModeEntry?.data) { + if (planModeEntry.data.enabled !== undefined) { + planModeEnabled = planModeEntry.data.enabled; + } + if (planModeEntry.data.todos) { + todoItems = planModeEntry.data.todos; + } + if (planModeEntry.data.executing) { + executionMode = planModeEntry.data.executing; + } + } + + if (planModeEnabled) { + pi.setActiveTools(PLAN_MODE_TOOLS); + } + updateStatus(ctx); + }); + + // Reset tool tracking at start of each turn and persist state + pi.on("turn_start", async () => { + toolsCalledThisTurn = false; + pi.appendEntry("plan-mode", { + enabled: planModeEnabled, + todos: todoItems, + executing: executionMode, + }); + }); + + // Handle non-tool turns (e.g., analysis, explanation steps) + pi.on("turn_end", async (_event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + + // If no tools were called this turn, the agent was doing analysis/explanation + // Mark the next uncompleted step as done + if (!toolsCalledThisTurn) { + const nextStep = todoItems.find((t) => !t.completed); + if (nextStep) { + nextStep.completed = true; + updateStatus(ctx); + } + } + }); +} diff --git a/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md b/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md new file mode 100644 index 000000000..a6706993f --- /dev/null +++ b/packages/coding-agent/examples/extensions/subagent/agents/reviewer.md @@ -0,0 +1,35 @@ +--- +name: reviewer +description: Code review specialist for quality and security analysis +tools: read, grep, find, ls, bash +model: claude-sonnet-4-5 +--- + +You are a senior code reviewer. Analyze code for quality, security, and maintainability. + +Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds. +Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only. + +Strategy: +1. Run `git diff` to see recent changes (if applicable) +2. Read the modified files +3. Check for bugs, security issues, code smells + +Output format: + +## Files Reviewed +- `path/to/file.ts` (lines X-Y) + +## Critical (must fix) +- `file.ts:42` - Issue description + +## Warnings (should fix) +- `file.ts:100` - Issue description + +## Suggestions (consider) +- `file.ts:150` - Improvement idea + +## Summary +Overall assessment in 2-3 sentences. + +Be specific with file paths and line numbers. diff --git a/packages/coding-agent/examples/extensions/todo.ts b/packages/coding-agent/examples/extensions/todo.ts new file mode 100644 index 000000000..0042d169e --- /dev/null +++ b/packages/coding-agent/examples/extensions/todo.ts @@ -0,0 +1,299 @@ +/** + * Todo Extension - Demonstrates state management via session entries + * + * This extension: + * - Registers a `todo` tool for the LLM to manage todos + * - Registers a `/todos` command for users to view the list + * + * State is stored in tool result details (not external files), which allows + * proper branching - when you branch, the todo state is automatically + * correct for that point in history. + */ + +import { StringEnum } from "@oh-my-pi/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@oh-my-pi/pi-coding-agent"; +import { matchesKey, Text, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +interface TodoDetails { + action: "list" | "add" | "toggle" | "clear"; + todos: Todo[]; + nextId: number; + error?: string; +} + +const TodoParams = Type.Object({ + action: StringEnum(["list", "add", "toggle", "clear"] as const), + text: Type.Optional(Type.String({ description: "Todo text (for add)" })), + id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), +}); + +/** + * UI component for the /todos command + */ +class TodoListComponent { + private todos: Todo[]; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(todos: Todo[], theme: Theme, onClose: () => void) { + this.todos = todos; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const th = this.theme; + + lines.push(""); + const title = th.fg("accent", " Todos "); + const headerLine = + th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); + lines.push(truncateToWidth(headerLine, width)); + lines.push(""); + + if (this.todos.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); + } else { + const done = this.todos.filter((t) => t.done).length; + const total = this.todos.length; + lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width)); + lines.push(""); + + for (const todo of this.todos) { + const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); + const id = th.fg("accent", `#${todo.id}`); + const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); + lines.push(truncateToWidth(` ${check} ${id} ${text}`, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +export default function (pi: ExtensionAPI) { + // In-memory state (reconstructed from session on load) + let todos: Todo[] = []; + let nextId = 1; + + /** + * Reconstruct state from session entries. + * Scans tool results for this tool and applies them in order. + */ + const reconstructState = (ctx: ExtensionContext) => { + todos = []; + nextId = 1; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = (entry as { message?: { role?: string; toolName?: string; details?: unknown } }).message; + if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue; + + const details = msg.details as TodoDetails | undefined; + if (details) { + todos = details.todos; + nextId = details.nextId; + } + } + }; + + // Reconstruct state on session events + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_branch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // Register the todo tool for the LLM + pi.registerTool({ + name: "todo", + label: "Todo", + description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", + parameters: TodoParams, + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + switch (params.action) { + case "list": + return { + content: [ + { + type: "text", + text: todos.length + ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") + : "No todos", + }, + ], + details: { action: "list", todos: [...todos], nextId } as TodoDetails, + }; + + case "add": { + if (!params.text) { + return { + content: [{ type: "text", text: "Error: text required for add" }], + details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, + }; + } + const newTodo: Todo = { id: nextId++, text: params.text, done: false }; + todos.push(newTodo); + return { + content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], + details: { action: "add", todos: [...todos], nextId } as TodoDetails, + }; + } + + case "toggle": { + if (params.id === undefined) { + return { + content: [{ type: "text", text: "Error: id required for toggle" }], + details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails, + }; + } + const todo = todos.find((t) => t.id === params.id); + if (!todo) { + return { + content: [{ type: "text", text: `Todo #${params.id} not found` }], + details: { + action: "toggle", + todos: [...todos], + nextId, + error: `#${params.id} not found`, + } as TodoDetails, + }; + } + todo.done = !todo.done; + return { + content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], + details: { action: "toggle", todos: [...todos], nextId } as TodoDetails, + }; + } + + case "clear": { + const count = todos.length; + todos = []; + nextId = 1; + return { + content: [{ type: "text", text: `Cleared ${count} todos` }], + details: { action: "clear", todos: [], nextId: 1 } as TodoDetails, + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { + action: "list", + todos: [...todos], + nextId, + error: `unknown action: ${params.action}`, + } as TodoDetails, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); + if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; + if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as TodoDetails | undefined; + if (!details) { + const text = result.content[0] as { type: string; text?: string } | undefined; + return new Text(text?.type === "text" && text.text ? text.text : "", 0, 0); + } + + if (details.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const todoList = details.todos; + + switch (details.action) { + case "list": { + if (todoList.length === 0) { + return new Text(theme.fg("dim", "No todos"), 0, 0); + } + let listText = theme.fg("muted", `${todoList.length} todo(s):`); + const display = expanded ? todoList : todoList.slice(0, 5); + for (const t of display) { + const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); + const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text); + listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`; + } + if (!expanded && todoList.length > 5) { + listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`; + } + return new Text(listText, 0, 0); + } + + case "add": { + const added = todoList[todoList.length - 1]; + return new Text( + theme.fg("success", "✓ Added ") + + theme.fg("accent", `#${added.id}`) + + " " + + theme.fg("muted", added.text), + 0, + 0, + ); + } + + case "toggle": { + const text = result.content[0] as { type: string; text?: string } | undefined; + const msg = text?.type === "text" && text.text ? text.text : ""; + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); + } + + case "clear": + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); + } + }, + }); + + // Register the /todos command for users + pi.registerCommand("todos", { + description: "Show all todos on the current branch", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/todos requires interactive mode", "error"); + return; + } + + await ctx.ui.custom((_tui, theme, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/tools.ts b/packages/coding-agent/examples/extensions/tools.ts new file mode 100644 index 000000000..46a37143c --- /dev/null +++ b/packages/coding-agent/examples/extensions/tools.ts @@ -0,0 +1,145 @@ +/** + * Tools Extension + * + * Provides a /tools command to enable/disable tools interactively. + * Tool selection persists across session reloads and respects branch navigation. + * + * Usage: + * 1. Copy this file to ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) or your project's .omp/extensions/ + * 2. Use /tools to open the tool selector + */ + +import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent"; +import { getSettingsListTheme } from "@oh-my-pi/pi-coding-agent"; +import { Container, type SettingItem, SettingsList } from "@oh-my-pi/pi-tui"; + +// State persisted to session +interface ToolsState { + enabledTools: string[]; +} + +export default function toolsExtension(pi: ExtensionAPI) { + // Track enabled tools + let enabledTools: Set = new Set(); + let allTools: string[] = []; + + // Persist current state + function persistState() { + pi.appendEntry("tools-config", { + enabledTools: Array.from(enabledTools), + }); + } + + // Apply current tool selection + function applyTools() { + pi.setActiveTools(Array.from(enabledTools)); + } + + // Find the last tools-config entry in the current branch + function restoreFromBranch(ctx: ExtensionContext) { + allTools = pi.getAllTools(); + + // Get entries in current branch only + const branchEntries = ctx.sessionManager.getBranch(); + let savedTools: string[] | undefined; + + for (const entry of branchEntries) { + if (entry.type === "custom" && (entry as { customType?: string }).customType === "tools-config") { + const data = (entry as { data?: ToolsState }).data; + if (data?.enabledTools) { + savedTools = data.enabledTools; + } + } + } + + if (savedTools) { + // Restore saved tool selection (filter to only tools that still exist) + enabledTools = new Set(savedTools.filter((t: string) => allTools.includes(t))); + applyTools(); + } else { + // No saved state - sync with currently active tools + enabledTools = new Set(pi.getActiveTools()); + } + } + + // Register /tools command + pi.registerCommand("tools", { + description: "Enable/disable tools", + handler: async (_args, ctx) => { + // Refresh tool list + allTools = pi.getAllTools(); + + await ctx.ui.custom((tui, theme, done) => { + // Build settings items for each tool + const items: SettingItem[] = allTools.map((tool) => ({ + id: tool, + label: tool, + currentValue: enabledTools.has(tool) ? "enabled" : "disabled", + values: ["enabled", "disabled"], + })); + + const container = new Container(); + container.addChild( + new (class { + render(_width: number) { + return [theme.fg("accent", theme.bold("Tool Configuration")), ""]; + } + invalidate() {} + })(), + ); + + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 15), + getSettingsListTheme(), + (id, newValue) => { + // Update enabled state and apply immediately + if (newValue === "enabled") { + enabledTools.add(id); + } else { + enabledTools.delete(id); + } + applyTools(); + persistState(); + }, + () => { + // Close dialog + done(undefined); + }, + ); + + container.addChild(settingsList); + + const component = { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + settingsList.handleInput?.(data); + tui.requestRender(); + }, + }; + + return component; + }); + }, + }); + + // Restore state on session start + pi.on("session_start", async (_event, ctx) => { + restoreFromBranch(ctx); + }); + + // Restore state when navigating the session tree + pi.on("session_tree", async (_event, ctx) => { + restoreFromBranch(ctx); + }); + + // Restore state after branching + pi.on("session_branch", async (_event, ctx) => { + restoreFromBranch(ctx); + }); +} diff --git a/packages/coding-agent/examples/extensions/with-deps/.gitignore b/packages/coding-agent/examples/extensions/with-deps/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/with-deps/index.ts b/packages/coding-agent/examples/extensions/with-deps/index.ts new file mode 100644 index 000000000..1300f735e --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/index.ts @@ -0,0 +1,36 @@ +/** + * Example extension with its own npm dependencies. + * Tests that jiti resolves modules from the extension's own node_modules. + * + * Requires: npm install in this directory + */ + +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import ms from "ms"; + +export default function (pi: ExtensionAPI) { + // Register a tool that uses ms + pi.registerTool({ + name: "parse_duration", + label: "Parse Duration", + description: "Parse a human-readable duration string (e.g., '2 days', '1h', '5m') to milliseconds", + parameters: Type.Object({ + duration: Type.String({ description: "Duration string like '2 days', '1h', '5m'" }), + }), + execute: async (_toolCallId, params) => { + const result = ms(params.duration as ms.StringValue); + if (result === undefined) { + return { + content: [{ type: "text", text: `Invalid duration: "${params.duration}"` }], + isError: true, + details: {}, + }; + } + return { + content: [{ type: "text", text: `${params.duration} = ${result} milliseconds` }], + details: {}, + }; + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json new file mode 100644 index 000000000..a8b996080 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "pi-extension-with-deps", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-with-deps", + "version": "1.0.0", + "dependencies": { + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/ms": "^2.1.0" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } +} diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json new file mode 100644 index 000000000..1edb82453 --- /dev/null +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -0,0 +1,16 @@ +{ + "name": "pi-extension-with-deps", + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/ms": "^2.1.0" + } +} diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts index 4752f7534..b32bf0f52 100644 --- a/packages/coding-agent/examples/sdk/02-custom-model.ts +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -8,8 +8,8 @@ import { getModel } from "@oh-my-pi/pi-ai"; import { createAgentSession, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent"; // Set up auth storage and model registry -const authStorage = discoverAuthStorage(); -const modelRegistry = discoverModels(authStorage); +const authStorage = await discoverAuthStorage(); +const modelRegistry = await discoverModels(authStorage); // Option 1: Find a specific built-in model by provider/id const opus = getModel("anthropic", "claude-opus-4-5"); @@ -24,7 +24,7 @@ if (customModel) { } // Option 3: Pick from available models (have valid API keys) -const available = await modelRegistry.getAvailable(); +const available = modelRegistry.getAvailable(); console.log( "Available models:", available.map((m) => `${m.provider}/${m.id}`), diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index 094071509..46997e2ba 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -39,17 +39,21 @@ console.log("Custom tools session created"); // With custom cwd - MUST use factory functions! const customCwd = "/path/to/project"; +const customTools = await createCodingTools(customCwd); await createAgentSession({ cwd: customCwd, - tools: createCodingTools(customCwd), // Tools resolve paths relative to customCwd + tools: customTools, // Tools resolve paths relative to customCwd sessionManager: SessionManager.inMemory(), }); console.log("Custom cwd session created"); // Or pick specific tools for custom cwd +const customReadTool = await createReadTool(customCwd); +const customBashTool = await createBashTool(customCwd); +const customGrepTool = await createGrepTool(customCwd); await createAgentSession({ cwd: customCwd, - tools: [createReadTool(customCwd), createBashTool(customCwd), createGrepTool(customCwd)], + tools: [customReadTool, customBashTool, customGrepTool], sessionManager: SessionManager.inMemory(), }); console.log("Specific tools with custom cwd session created"); @@ -69,7 +73,7 @@ const weatherTool: CustomTool = { }; const { session } = await createAgentSession({ - customTools: [{ tool: weatherTool }], + customTools: [weatherTool], sessionManager: SessionManager.inMemory(), }); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts new file mode 100644 index 000000000..cefa23050 --- /dev/null +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -0,0 +1,81 @@ +/** + * Extensions Configuration + * + * Extensions intercept agent events and can register custom tools. + * They provide a unified system for extensions, custom tools, commands, and more. + * + * Extension files are discovered from: + * - ~/.omp/agent/extensions/ (legacy: ~/.pi/agent/extensions/) + * - /.omp/extensions/ (legacy: /.pi/extensions/) + * - Paths specified in settings.json "extensions" array + * - Paths passed via --extension CLI flag + * + * An extension is a TypeScript file that exports a default function: + * export default function (pi: ExtensionAPI) { ... } + */ + +import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent"; + +// Extensions are loaded from disk, not passed inline to createAgentSession. +// Use the discovery mechanism: +// 1. Place extension files in ~/.omp/agent/extensions/ or .omp/extensions/ +// 2. Add paths to settings.json: { "extensions": ["./my-extension.ts"] } +// 3. Use --extension flag: pi --extension ./my-extension.ts + +// To add additional extension paths beyond discovery: +const { session } = await createAgentSession({ + additionalExtensionPaths: ["./my-logging-extension.ts", "./my-safety-extension.ts"], + sessionManager: SessionManager.inMemory(), +}); + +session.subscribe((event) => { + if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { + process.stdout.write(event.assistantMessageEvent.delta); + } +}); + +await session.prompt("List files in the current directory."); +console.log(); + +// Example extension file (./my-logging-extension.ts): +/* +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("agent_start", async () => { + console.log("[Extension] Agent starting"); + }); + + pi.on("tool_call", async (event) => { + console.log(\`[Extension] Tool: \${event.toolName}\`); + // Return { block: true, reason: "..." } to block execution + return undefined; + }); + + pi.on("agent_end", async (event) => { + console.log(\`[Extension] Done, \${event.messages.length} messages\`); + }); + + // Register a custom tool + pi.registerTool({ + name: "my_tool", + label: "My Tool", + description: "Does something useful", + parameters: Type.Object({ + input: Type.String(), + }), + execute: async (_toolCallId, params, _onUpdate, _ctx, _signal) => ({ + content: [{ type: "text", text: \`Processed: \${params.input}\` }], + details: {}, + }), + }); + + // Register a command + pi.registerCommand("mycommand", { + description: "Do something", + handler: async (args, ctx) => { + ctx.ui.notify(\`Command executed with: \${args}\`); + }, + }); +} +*/ diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts index 108cc3fff..48fa565a4 100644 --- a/packages/coding-agent/examples/sdk/06-hooks.ts +++ b/packages/coding-agent/examples/sdk/06-hooks.ts @@ -2,12 +2,13 @@ * Hooks Configuration * * Hooks intercept agent events for logging, blocking, or modification. + * Note: "hooks" is now called "extensions" in the API. */ -import { createAgentSession, type HookFactory, SessionManager } from "@oh-my-pi/pi-coding-agent"; +import { createAgentSession, type ExtensionFactory, SessionManager } from "@oh-my-pi/pi-coding-agent"; -// Logging hook -const loggingHook: HookFactory = (api) => { +// Logging hook (now called extension) +const loggingHook: ExtensionFactory = (api) => { api.on("agent_start", async () => { console.log("[Hook] Agent starting"); }); @@ -22,8 +23,8 @@ const loggingHook: HookFactory = (api) => { }); }; -// Blocking hook (returns { block: true, reason: "..." }) -const safetyHook: HookFactory = (api) => { +// Blocking extension (returns { block: true, reason: "..." }) +const safetyHook: ExtensionFactory = (api) => { api.on("tool_call", async (event) => { if (event.toolName === "bash") { const cmd = (event.input as { command?: string }).command ?? ""; @@ -35,9 +36,9 @@ const safetyHook: HookFactory = (api) => { }); }; -// Use inline hooks +// Use inline extensions (hooks is now extensions) const { session } = await createAgentSession({ - hooks: [{ factory: loggingHook }, { factory: safetyHook }], + extensions: [loggingHook, safetyHook], sessionManager: SessionManager.inMemory(), }); @@ -50,12 +51,12 @@ session.subscribe((event) => { await session.prompt("List files in the current directory."); console.log(); -// Disable all hooks: -// hooks: [] +// Disable all extensions: +// extensions: [] -// Merge with discovered hooks: -// const discovered = await discoverHooks(); -// hooks: [...discovered, { factory: myHook }] +// Merge with discovered extensions: +// const discovered = await discoverExtensions(); +// extensions: [...discovered.extensions.map(e => e.factory), myHook] // Add paths without replacing discovery: -// additionalHookPaths: ["/extra/hooks"] +// additionalExtensionPaths: ["/extra/extensions"] diff --git a/packages/coding-agent/examples/sdk/08-prompt-templates.ts b/packages/coding-agent/examples/sdk/08-prompt-templates.ts new file mode 100644 index 000000000..84752816a --- /dev/null +++ b/packages/coding-agent/examples/sdk/08-prompt-templates.ts @@ -0,0 +1,42 @@ +/** + * Prompt Templates + * + * File-based templates that inject content when invoked with /templatename. + */ + +import { + createAgentSession, + discoverPromptTemplates, + type PromptTemplate, + SessionManager, +} from "@oh-my-pi/pi-coding-agent"; + +// Discover templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/ +const discovered = await discoverPromptTemplates(); +console.log("Discovered prompt templates:"); +for (const template of discovered) { + console.log(` /${template.name}: ${template.description}`); +} + +// Define custom templates +const deployTemplate: PromptTemplate = { + name: "deploy", + description: "Deploy the application", + source: "(custom)", + content: `# Deploy Instructions + +1. Build: npm run build +2. Test: npm test +3. Deploy: npm run deploy`, +}; + +// Use discovered + custom templates +await createAgentSession({ + promptTemplates: [...discovered, deployTemplate], + sessionManager: SessionManager.inMemory(), +}); + +console.log(`Session created with ${discovered.length + 1} prompt templates`); + +// Disable prompt templates: +// promptTemplates: [] diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts index d4148b7e1..880dc6416 100644 --- a/packages/coding-agent/examples/sdk/08-slash-commands.ts +++ b/packages/coding-agent/examples/sdk/08-slash-commands.ts @@ -2,24 +2,25 @@ * Slash Commands * * File-based commands that inject content when invoked with /commandname. + * Note: File-based slash commands are now called "prompt templates". */ import { createAgentSession, - discoverSlashCommands, - type FileSlashCommand, + discoverPromptTemplates, + type PromptTemplate, SessionManager, } from "@oh-my-pi/pi-coding-agent"; -// Discover commands from cwd/.omp/commands/ and ~/.omp/agent/commands/ -const discovered = discoverSlashCommands(); -console.log("Discovered slash commands:"); +// Discover prompt templates from cwd/.pi/prompts/ and ~/.pi/agent/prompts/ +const discovered = await discoverPromptTemplates(); +console.log("Discovered prompt templates:"); for (const cmd of discovered) { console.log(` /${cmd.name}: ${cmd.description}`); } -// Define custom commands -const deployCommand: FileSlashCommand = { +// Define custom prompt templates +const deployCommand: PromptTemplate = { name: "deploy", description: "Deploy the application", source: "(custom)", @@ -30,13 +31,17 @@ const deployCommand: FileSlashCommand = { 3. Deploy: npm run deploy`, }; -// Use discovered + custom commands +// Note: slashCommands is now managed by the agent session automatically. +// Custom commands can be loaded via discoverCustomTSCommands() for TypeScript commands. +// For file-based markdown commands, use promptTemplates instead. + +// Convert file-based slash commands to prompt templates await createAgentSession({ - slashCommands: [...discovered, deployCommand], + promptTemplates: [...discovered, deployCommand], sessionManager: SessionManager.inMemory(), }); -console.log(`Session created with ${discovered.length + 1} slash commands`); +console.log(`Session created with ${discovered.length + 1} prompt templates`); -// Disable slash commands: -// slashCommands: [] +// Disable prompt templates: +// promptTemplates: [] diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 02498b635..4793ef16d 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -15,8 +15,8 @@ import { // Default: discoverAuthStorage() uses ~/.omp/agent/auth.json // discoverModels() loads built-in + custom models from ~/.omp/agent/models.json -const authStorage = discoverAuthStorage(); -const modelRegistry = discoverModels(authStorage); +const authStorage = await discoverAuthStorage(); +const modelRegistry = await discoverModels(authStorage); await createAgentSession({ sessionManager: SessionManager.inMemory(), diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index e940fb3de..ed053ef54 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -15,7 +15,7 @@ import { createAgentSession, createBashTool, createReadTool, - type HookFactory, + type ExtensionFactory, ModelRegistry, SessionManager, SettingsManager, @@ -33,8 +33,8 @@ if (process.env.MY_ANTHROPIC_KEY) { // Model registry with no custom models.json const modelRegistry = new ModelRegistry(authStorage); -// Inline hook -const auditHook: HookFactory = (api) => { +// Inline extension +const auditHook: ExtensionFactory = (api) => { api.on("tool_call", async (event) => { console.log(`[Audit] ${event.toolName}`); return undefined; @@ -76,11 +76,11 @@ const { session } = await createAgentSession({ Available: read, bash, status. Be concise.`, // Use factory functions with the same cwd to ensure path resolution works correctly tools: [createReadTool(cwd), createBashTool(cwd)], - customTools: [{ tool: statusTool }], - hooks: [{ factory: auditHook }], + customTools: [statusTool], + extensions: [auditHook], skills: [], contextFiles: [], - slashCommands: [], + promptTemplates: [], sessionManager: SessionManager.inMemory(), settingsManager, }); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 30bc3ce86..1646a5117 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -67,9 +67,10 @@ }, "devDependencies": { "@types/diff": "^7.0.2", + "@types/ms": "^2.1.0", "@types/ndjson": "^2.0.4", "@types/node": "^24.3.0", - "vitest": "^3.2.4" + "ms": "^2.1.3" }, "keywords": [ "coding-agent", diff --git a/packages/coding-agent/src/capability/extension-module.ts b/packages/coding-agent/src/capability/extension-module.ts new file mode 100644 index 000000000..ab5e1b692 --- /dev/null +++ b/packages/coding-agent/src/capability/extension-module.ts @@ -0,0 +1,34 @@ +/** + * Extension Modules Capability + * + * TypeScript/JavaScript extension modules loaded by the extension system. + */ + +import { defineCapability } from "./index"; +import type { SourceMeta } from "./types"; + +/** + * A loaded extension module. + */ +export interface ExtensionModule { + /** Extension module name (derived from path) */ + name: string; + /** Absolute path to extension entrypoint */ + path: string; + /** Source level */ + level: "user" | "project"; + /** Source metadata */ + _source: SourceMeta; +} + +export const extensionModuleCapability = defineCapability({ + id: "extension-modules", + displayName: "Extension Modules", + description: "TypeScript/JavaScript extension modules loaded by the extension system", + key: (ext) => ext.name, + validate: (ext) => { + if (!ext.name) return "Missing name"; + if (!ext.path) return "Missing path"; + return undefined; + }, +}); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 2468a941a..f185a7fc2 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -29,7 +29,7 @@ export interface Args { models?: string[]; tools?: ToolName[]; hooks?: string[]; - customTools?: string[]; + extensions?: string[]; print?: boolean; export?: string; noSkills?: boolean; @@ -37,6 +37,8 @@ export interface Args { listModels?: string | true; messages: string[]; fileArgs: string[]; + /** Unknown flags (potentially extension flags) - map of flag name to value */ + unknownFlags: Map; } const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -45,10 +47,11 @@ export function isValidThinkingLevel(level: string): level is ThinkingLevel { return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); } -export function parseArgs(args: string[]): Args { +export function parseArgs(args: string[], extensionFlags?: Map): Args { const result: Args = { messages: [], fileArgs: [], + unknownFlags: new Map(), }; for (let i = 0; i < args.length; i++) { @@ -120,9 +123,9 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--hook" && i + 1 < args.length) { result.hooks = result.hooks ?? []; result.hooks.push(args[++i]); - } else if (arg === "--tool" && i + 1 < args.length) { - result.customTools = result.customTools ?? []; - result.customTools.push(args[++i]); + } else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) { + result.extensions = result.extensions ?? []; + result.extensions.push(args[++i]); } else if (arg === "--no-skills") { result.noSkills = true; } else if (arg === "--skills" && i + 1 < args.length) { @@ -137,6 +140,18 @@ export function parseArgs(args: string[]): Args { } } else if (arg.startsWith("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix + } else if (arg.startsWith("--") && extensionFlags) { + // Check if it's an extension-registered flag + const flagName = arg.slice(2); + const extFlag = extensionFlags.get(flagName); + if (extFlag) { + if (extFlag.type === "boolean") { + result.unknownFlags.set(flagName, true); + } else if (extFlag.type === "string" && i + 1 < args.length) { + result.unknownFlags.set(flagName, args[++i]); + } + } + // Unknown flags without extensionFlags are silently ignored (first pass) } else if (!arg.startsWith("-")) { result.messages.push(arg); } @@ -170,8 +185,8 @@ ${chalk.bold("Options:")} --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh - --hook Load a hook file (can be used multiple times) - --tool Load a custom tool file (can be used multiple times) + --hook Load a hook/extension file (can be used multiple times) + --extension, -e Load an extension file (can be used multiple times) --no-skills Disable skills discovery and loading --skills Comma-separated glob patterns to filter skills (e.g., git-*,docker) --export Export session file to HTML and exit diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 8ca4ddf25..45dc51474 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -2,12 +2,12 @@ * Process @file CLI arguments into text content and image attachments */ -import { access, readFile, stat } from "node:fs/promises"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import chalk from "chalk"; -import sharp from "sharp"; import { resolveReadPath } from "../core/tools/path-utils"; +import { formatDimensionNote, resizeImage } from "../utils/image-resize"; import { detectSupportedImageMimeTypeFromFile } from "../utils/mime"; export interface ProcessedFiles { @@ -15,55 +15,14 @@ export interface ProcessedFiles { images: ImageContent[]; } -const RESIZE_TRIGGER_MAX_DIMENSION = 2048; -const MAX_RESIZE_WIDTH = 1920; -const MAX_RESIZE_HEIGHT = 1080; -const JPEG_CONVERT_THRESHOLD_BYTES = 2 * 1024 * 1024; -const JPEG_QUALITY = 85; - -async function processImageAttachment(buffer: Buffer, mimeType: string): Promise<{ buffer: Buffer; mimeType: string }> { - const metadata = await sharp(buffer, { failOnError: false }).metadata(); - const width = metadata.width ?? 0; - const height = metadata.height ?? 0; - const maxDim = Math.max(width, height); - const shouldResize = width > 0 && height > 0 && maxDim > RESIZE_TRIGGER_MAX_DIMENSION; - const shouldConvertToJpeg = buffer.length > JPEG_CONVERT_THRESHOLD_BYTES; - - if (!shouldResize && !shouldConvertToJpeg) { - return { buffer, mimeType }; - } - - let pipeline = sharp(buffer, { failOnError: false }); - if (shouldResize) { - pipeline = pipeline.resize({ - width: MAX_RESIZE_WIDTH, - height: MAX_RESIZE_HEIGHT, - fit: "inside", - withoutEnlargement: true, - }); - } - - if (shouldConvertToJpeg) { - pipeline = pipeline.jpeg({ quality: JPEG_QUALITY }); - return { buffer: await pipeline.toBuffer(), mimeType: "image/jpeg" }; - } - - if (mimeType === "image/png") { - pipeline = pipeline.png(); - } else if (mimeType === "image/webp") { - pipeline = pipeline.webp(); - } else if (mimeType === "image/gif") { - pipeline = pipeline.gif(); - } else { - pipeline = pipeline.jpeg({ quality: JPEG_QUALITY }); - return { buffer: await pipeline.toBuffer(), mimeType: "image/jpeg" }; - } - - return { buffer: await pipeline.toBuffer(), mimeType }; +export interface ProcessFileOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; } /** Process @file arguments into text content and image attachments */ -export async function processFileArguments(fileArgs: string[]): Promise { +export async function processFileArguments(fileArgs: string[], options?: ProcessFileOptions): Promise { + const _autoResizeImages = options?.autoResizeImages ?? true; let text = ""; const images: ImageContent[] = []; @@ -71,16 +30,12 @@ export async function processFileArguments(fileArgs: string[]): Promise\n`; + // Add text reference to image with optional dimension note + if (dimensionNote) { + text += `${dimensionNote}\n`; + } else { + text += `\n`; + } } else { // Handle text file try { - const content = await readFile(absolutePath, "utf-8"); + const content = readFileSync(absolutePath, "utf-8"); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts index 2d7afffef..ca8aee8f9 100644 --- a/packages/coding-agent/src/cli/list-models.ts +++ b/packages/coding-agent/src/cli/list-models.ts @@ -25,7 +25,7 @@ function formatTokenCount(count: number): string { * List available models, optionally filtered by search pattern */ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise { - const models = await modelRegistry.getAvailable(); + const models = modelRegistry.getAvailable(); if (models.length === 0) { console.log("No models available. Set API keys in environment variables."); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 531fcfba0..d9532e93f 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -104,6 +104,11 @@ export function getCommandsDir(): string { return join(getAgentDir(), "commands"); } +/** Get path to prompts directory */ +export function getPromptsDir(): string { + return join(getAgentDir(), "prompts"); +} + /** Get path to sessions directory */ export function getSessionsDir(): string { return join(getAgentDir(), "sessions"); @@ -230,8 +235,8 @@ export function readConfigFile( for (const { path: base, source, level } of dirs) { const filePath = join(base, subpath); - if (existsSync(filePath)) { - try { + try { + if (existsSync(filePath)) { const content = readFileSync(filePath, "utf-8"); return { path: filePath, @@ -239,9 +244,9 @@ export function readConfigFile( level, content: JSON.parse(content) as T, }; - } catch { - // Continue to next file on parse error } + } catch { + // Continue to next file on parse error } } @@ -261,8 +266,8 @@ export function readAllConfigFiles( for (const { path: base, source, level } of dirs) { const filePath = join(base, subpath); - if (existsSync(filePath)) { - try { + try { + if (existsSync(filePath)) { const content = readFileSync(filePath, "utf-8"); results.push({ path: filePath, @@ -270,9 +275,9 @@ export function readAllConfigFiles( level, content: JSON.parse(content) as T, }); - } catch { - // Skip files that fail to parse } + } catch { + // Skip files that fail to parse } } @@ -319,9 +324,9 @@ export function findConfigFileWithMeta( // Walk-Up Config Discovery (for monorepo scenarios) // ============================================================================= -function isDirectory(p: string): boolean { +async function isDirectory(p: string): Promise { try { - return statSync(p).isDirectory(); + return existsSync(p) && statSync(p).isDirectory(); } catch { return false; } @@ -335,14 +340,17 @@ function isDirectory(p: string): boolean { * @param cwd - Starting directory * @returns First existing directory found, or undefined */ -export function findNearestProjectConfigDir(subpath: string, cwd: string = process.cwd()): ConfigDirEntry | undefined { +export async function findNearestProjectConfigDir( + subpath: string, + cwd: string = process.cwd(), +): Promise { let currentDir = cwd; while (true) { // Check all config bases at this level, in priority order for (const { base, name } of PROJECT_CONFIG_BASES) { const candidate = join(currentDir, base, subpath); - if (isDirectory(candidate)) { + if (await isDirectory(candidate)) { return { path: candidate, source: name, level: "project" }; } } @@ -361,7 +369,10 @@ export function findNearestProjectConfigDir(subpath: string, cwd: string = proce * Returns one entry per config base (.omp, .pi, .claude) - the nearest one found. * Results are in priority order (highest first). */ -export function findAllNearestProjectConfigDirs(subpath: string, cwd: string = process.cwd()): ConfigDirEntry[] { +export async function findAllNearestProjectConfigDirs( + subpath: string, + cwd: string = process.cwd(), +): Promise { const results: ConfigDirEntry[] = []; const foundBases = new Set(); @@ -372,7 +383,7 @@ export function findAllNearestProjectConfigDirs(subpath: string, cwd: string = p if (foundBases.has(name)) continue; const candidate = join(currentDir, base, subpath); - if (isDirectory(candidate)) { + if (await isDirectory(candidate)) { results.push({ path: candidate, source: name, level: "project" }); foundBases.add(name); } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e4c398822..a2a24a450 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,11 +13,12 @@ * Modes use this class and add their own I/O layer on top. */ -import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, TextContent, Usage } from "@oh-my-pi/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai"; import type { Rule } from "../capability/rule"; import { getAuthPath } from "../config"; +import { theme } from "../modes/interactive/theme/theme"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor"; import { type CompactionResult, @@ -29,11 +30,11 @@ import { shouldCompact, } from "./compaction/index"; import type { LoadedCustomCommand } from "./custom-commands/index"; -import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index"; import { exportSessionToHtml } from "./export-html/index"; -import { extractFileMentions, generateFileMentionMessages } from "./file-mentions"; import type { - HookRunner, + ExtensionCommandContext, + ExtensionRunner, + ExtensionUIContext, SessionBeforeBranchResult, SessionBeforeCompactResult, SessionBeforeSwitchResult, @@ -41,14 +42,15 @@ import type { TreePreparation, TurnEndEvent, TurnStartEvent, -} from "./hooks/index"; -import { logger } from "./logger"; -import type { BashExecutionMessage, HookMessage } from "./messages"; +} from "./extensions"; +import { extractFileMentions, generateFileMentionMessages } from "./file-mentions"; +import type { HookCommandContext } from "./hooks/types"; +import type { BashExecutionMessage, CustomMessage } from "./messages"; import type { ModelRegistry } from "./model-registry"; import { parseModelString } from "./model-resolver"; +import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager"; import type { SettingsManager, SkillsSettings } from "./settings-manager"; -import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands"; import type { TtsrManager } from "./ttsr"; /** Session-specific events that extend the core AgentEvent */ @@ -73,27 +75,31 @@ export interface AgentSessionConfig { settingsManager: SettingsManager; /** Models to cycle through with Ctrl+P (from --models flag) */ scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - /** File-based slash commands for expansion */ - fileCommands?: FileSlashCommand[]; - /** Hook runner (created in main.ts with wrapped tools) */ - hookRunner?: HookRunner; - /** Custom tools for session lifecycle events */ - customTools?: LoadedCustomTool[]; + /** Prompt templates for expansion */ + promptTemplates?: PromptTemplate[]; + /** Extension runner (created in main.ts with wrapped tools) */ + extensionRunner?: ExtensionRunner; /** Custom commands (TypeScript slash commands) */ customCommands?: LoadedCustomCommand[]; skillsSettings?: Required; /** Model registry for API key resolution and model discovery */ modelRegistry: ModelRegistry; + /** Tool registry for LSP and settings */ + toolRegistry?: Map; + /** System prompt builder that can consider tool availability */ + rebuildSystemPrompt?: (toolNames: string[]) => string; /** TTSR manager for time-traveling stream rules */ ttsrManager?: TtsrManager; } /** Options for AgentSession.prompt() */ export interface PromptOptions { - /** Whether to expand file-based slash commands (default: true) */ - expandSlashCommands?: boolean; + /** Whether to expand file-based prompt templates (default: true) */ + expandPromptTemplates?: boolean; /** Image attachments */ images?: ImageContent[]; + /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */ + streamingBehavior?: "steer" | "followUp"; } /** Result from cycleModel() */ @@ -141,6 +147,23 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "hi /** Thinking levels including xhigh (for supported models) */ const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; +const noOpUIContext: ExtensionUIContext = { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return theme; + }, +}; + // ============================================================================ // AgentSession Class // ============================================================================ @@ -151,14 +174,18 @@ export class AgentSession { readonly settingsManager: SettingsManager; private _scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - private _fileCommands: FileSlashCommand[]; + private _promptTemplates: PromptTemplate[]; // Event subscription state private _unsubscribeAgent?: () => void; private _eventListeners: AgentSessionEventListener[] = []; - // Message queue state - private _queuedMessages: string[] = []; + /** Tracks pending steering messages for UI display. Removed when delivered. */ + private _steeringMessages: string[] = []; + /** Tracks pending follow-up messages for UI display. Removed when delivered. */ + private _followUpMessages: string[] = []; + /** Messages queued to be included with the next user prompt as context ("asides"). */ + private _pendingNextTurnMessages: CustomMessage[] = []; // Compaction state private _compactionAbortController: AbortController | undefined = undefined; @@ -177,13 +204,10 @@ export class AgentSession { private _bashAbortController: AbortController | undefined = undefined; private _pendingBashMessages: BashExecutionMessage[] = []; - // Hook system - private _hookRunner: HookRunner | undefined = undefined; + // Extension system + private _extensionRunner: ExtensionRunner | undefined = undefined; private _turnIndex = 0; - // Custom tools for session lifecycle - private _customTools: LoadedCustomTool[] = []; - // Custom commands (TypeScript slash commands) private _customCommands: LoadedCustomCommand[] = []; @@ -192,6 +216,11 @@ export class AgentSession { // Model registry for API key resolution private _modelRegistry: ModelRegistry; + // Tool registry and prompt builder for extensions + private _toolRegistry: Map; + private _rebuildSystemPrompt: ((toolNames: string[]) => string) | undefined; + private _baseSystemPrompt: string; + // TTSR manager for time-traveling stream rules private _ttsrManager: TtsrManager | undefined = undefined; private _pendingTtsrInjections: Rule[] = []; @@ -202,12 +231,14 @@ export class AgentSession { this.sessionManager = config.sessionManager; this.settingsManager = config.settingsManager; this._scopedModels = config.scopedModels ?? []; - this._fileCommands = config.fileCommands ?? []; - this._hookRunner = config.hookRunner; - this._customTools = config.customTools ?? []; + this._promptTemplates = config.promptTemplates ?? []; + this._extensionRunner = config.extensionRunner; this._customCommands = config.customCommands ?? []; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; + this._toolRegistry = config.toolRegistry ?? new Map(); + this._rebuildSystemPrompt = config.rebuildSystemPrompt; + this._baseSystemPrompt = this.agent.state.systemPrompt; this._ttsrManager = config.ttsrManager; // Always subscribe to agent events for internal handling @@ -248,22 +279,27 @@ export class AgentSession { /** Internal handler for agent events - shared by subscribe and reconnect */ private _handleAgentEvent = async (event: AgentEvent): Promise => { - // When a user message starts, check if it's from the queue and remove it BEFORE emitting + // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state - if (event.type === "message_start" && event.message.role === "user" && this._queuedMessages.length > 0) { - // Extract text content from the message + if (event.type === "message_start" && event.message.role === "user") { const messageText = this._getUserMessageText(event.message); - if (messageText && this._queuedMessages.includes(messageText)) { - // Remove the first occurrence of this message from the queue - const index = this._queuedMessages.indexOf(messageText); - if (index !== -1) { - this._queuedMessages.splice(index, 1); + if (messageText) { + // Check steering queue first + const steeringIndex = this._steeringMessages.indexOf(messageText); + if (steeringIndex !== -1) { + this._steeringMessages.splice(steeringIndex, 1); + } else { + // Check follow-up queue + const followUpIndex = this._followUpMessages.indexOf(messageText); + if (followUpIndex !== -1) { + this._followUpMessages.splice(followUpIndex, 1); + } } } } - // Emit to hooks first - await this._emitHookEvent(event); + // Emit to extensions first + await this._emitExtensionEvent(event); // Notify all listeners this._emit(event); @@ -324,8 +360,8 @@ export class AgentSession { // Handle session persistence if (event.type === "message_end") { - // Check if this is a hook message - if (event.message.role === "hookMessage") { + // Check if this is a hook/custom message + if (event.message.role === "hookMessage" || event.message.role === "custom") { // Persist as CustomMessageEntry this.sessionManager.appendCustomMessageEntry( event.message.customType, @@ -420,22 +456,22 @@ export class AgentSession { return undefined; } - /** Emit hook events based on agent events */ - private async _emitHookEvent(event: AgentEvent): Promise { - if (!this._hookRunner) return; + /** Emit extension events based on agent events */ + private async _emitExtensionEvent(event: AgentEvent): Promise { + if (!this._extensionRunner) return; if (event.type === "agent_start") { this._turnIndex = 0; - await this._hookRunner.emit({ type: "agent_start" }); + await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - await this._hookRunner.emit({ type: "agent_end", messages: event.messages }); + await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { const hookEvent: TurnStartEvent = { type: "turn_start", turnIndex: this._turnIndex, timestamp: Date.now(), }; - await this._hookRunner.emit(hookEvent); + await this._extensionRunner.emit(hookEvent); } else if (event.type === "turn_end") { const hookEvent: TurnEndEvent = { type: "turn_end", @@ -443,7 +479,7 @@ export class AgentSession { message: event.message, toolResults: event.toolResults, }; - await this._hookRunner.emit(hookEvent); + await this._extensionRunner.emit(hookEvent); this._turnIndex++; } } @@ -520,6 +556,53 @@ export class AgentSession { return this.agent.state.isStreaming; } + /** + * Get the names of currently active tools. + * Returns the names of tools currently set on the agent. + */ + getActiveToolNames(): string[] { + return this.agent.state.tools.map((t) => t.name); + } + + /** + * Get a tool by name from the registry. + */ + getToolByName(name: string): AgentTool | undefined { + return this._toolRegistry.get(name); + } + + /** + * Get all configured tool names (built-in via --tools or default, plus custom tools). + */ + getAllToolNames(): string[] { + return Array.from(this._toolRegistry.keys()); + } + + /** + * Set active tools by name. + * Only tools in the registry can be enabled. Unknown tool names are ignored. + * Also rebuilds the system prompt to reflect the new tool set. + * Changes take effect on the next agent turn. + */ + setActiveToolsByName(toolNames: string[]): void { + const tools: AgentTool[] = []; + const validToolNames: string[] = []; + for (const name of toolNames) { + const tool = this._toolRegistry.get(name); + if (tool) { + tools.push(tool); + validToolNames.push(name); + } + } + this.agent.setTools(tools); + + // Rebuild base system prompt with new tool set + if (this._rebuildSystemPrompt) { + this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); + this.agent.setSystemPrompt(this._baseSystemPrompt); + } + } + /** Whether auto-compaction is currently running */ get isCompacting(): boolean { return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; @@ -530,9 +613,14 @@ export class AgentSession { return this.agent.state.messages; } - /** Current queue mode */ - get queueMode(): "all" | "one-at-a-time" { - return this.agent.getQueueMode(); + /** Current steering mode */ + get steeringMode(): "all" | "one-at-a-time" { + return this.agent.getSteeringMode(); + } + + /** Current follow-up mode */ + get followUpMode(): "all" | "one-at-a-time" { + return this.agent.getFollowUpMode(); } /** Current interrupt mode */ @@ -555,9 +643,9 @@ export class AgentSession { return this._scopedModels; } - /** File-based slash commands */ - get fileCommands(): ReadonlyArray { - return this._fileCommands; + /** Prompt templates */ + get promptTemplates(): ReadonlyArray { + return this._promptTemplates; } /** Custom commands (TypeScript slash commands) */ @@ -571,22 +659,20 @@ export class AgentSession { /** * Send a prompt to the agent. - * - Validates model and API key before sending - * - Handles hook commands (registered via pi.registerCommand) - * - Expands file-based slash commands by default - * @throws Error if no model selected or no API key available + * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming + * - Expands file-based prompt templates by default + * - During streaming, queues via steer() or followUp() based on streamingBehavior option + * - Validates model and API key before sending (when not streaming) + * @throws Error if streaming and no streamingBehavior specified + * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { - // Flush any pending bash messages before the new prompt - this._flushPendingBashMessages(); + const expandPromptTemplates = options?.expandPromptTemplates ?? true; - const expandCommands = options?.expandSlashCommands ?? true; - - // Handle hook commands first (if enabled and text is a slash command) - if (expandCommands && text.startsWith("/")) { - const handled = await this._tryExecuteHookCommand(text); + // Handle extension commands first (execute immediately, even during streaming) + if (expandPromptTemplates && text.startsWith("/")) { + const handled = await this._tryExecuteExtensionCommand(text); if (handled) { - // Hook command executed, no prompt to send return; } @@ -594,14 +680,33 @@ export class AgentSession { const customResult = await this._tryExecuteCustomCommand(text); if (customResult !== null) { if (customResult === "") { - // Command handled, nothing to send return; } - // Command returned a prompt - use it instead of the original text text = customResult; } } + // Expand file-based prompt templates if requested + const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text; + + // If streaming, queue via steer() or followUp() based on option + if (this.isStreaming) { + if (!options?.streamingBehavior) { + throw new Error( + "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + ); + } + if (options.streamingBehavior === "followUp") { + await this._queueFollowUp(expandedText); + } else { + await this._queueSteer(expandedText); + } + return; + } + + // Flush any pending bash messages before the new prompt + this._flushPendingBashMessages(); + // Validate model if (!this.model) { throw new Error( @@ -626,10 +731,7 @@ export class AgentSession { await this._checkCompaction(lastAssistant, false); } - // Expand file-based slash commands if requested - const expandedText = expandCommands ? expandSlashCommand(text, [...this._fileCommands]) : text; - - // Build messages array (hook message if any, then user message) + // Build messages array (custom messages if any, then user message) const messages: AgentMessage[] = []; // Add user message @@ -643,6 +745,12 @@ export class AgentSession { timestamp: Date.now(), }); + // Inject any pending "nextTurn" messages as context alongside the user message + for (const msg of this._pendingNextTurnMessages) { + messages.push(msg); + } + this._pendingNextTurnMessages = []; + // Auto-read @filepath mentions const fileMentions = extractFileMentions(expandedText); if (fileMentions.length > 0) { @@ -650,18 +758,26 @@ export class AgentSession { messages.push(...fileMentionMessages); } - // Emit before_agent_start hook event - if (this._hookRunner) { - const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); - if (result?.message) { - messages.push({ - role: "hookMessage", - customType: result.message.customType, - content: result.message.content, - display: result.message.display, - details: result.message.details, - timestamp: Date.now(), - }); + // Emit before_agent_start extension event + if (this._extensionRunner) { + const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images); + if (result?.messages) { + for (const msg of result.messages) { + messages.push({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + timestamp: Date.now(), + }); + } + } + + if (result?.systemPromptAppend) { + this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`); + } else { + this.agent.setSystemPrompt(this._baseSystemPrompt); } } @@ -670,29 +786,29 @@ export class AgentSession { } /** - * Try to execute a hook command. Returns true if command was found and executed. + * Try to execute an extension command. Returns true if command was found and executed. */ - private async _tryExecuteHookCommand(text: string): Promise { - if (!this._hookRunner) return false; + private async _tryExecuteExtensionCommand(text: string): Promise { + if (!this._extensionRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); - const command = this._hookRunner.getCommand(commandName); + const command = this._extensionRunner.getCommand(commandName); if (!command) return false; - // Get command context from hook runner (includes session control methods) - const ctx = this._hookRunner.createCommandContext(); + // Get command context from extension runner (includes session control methods) + const ctx = this._extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { - // Emit error via hook runner - this._hookRunner.emitError({ - hookPath: `command:${commandName}`, + // Emit error via extension runner + this._extensionRunner.emitError({ + extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), }); @@ -700,13 +816,52 @@ export class AgentSession { } } + private _createCommandContext(): ExtensionCommandContext { + if (this._extensionRunner) { + return this._extensionRunner.createCommandContext(); + } + + return { + ui: noOpUIContext, + hasUI: false, + cwd: this.sessionManager.getCwd(), + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, + model: this.model ?? undefined, + isIdle: () => !this.isStreaming, + abort: () => { + void this.abort(); + }, + hasPendingMessages: () => this.queuedMessageCount > 0, + hasQueuedMessages: () => this.queuedMessageCount > 0, + waitForIdle: () => this.agent.waitForIdle(), + newSession: async (options) => { + const success = await this.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } + if (options?.setup) { + await options.setup(this.sessionManager); + } + return { cancelled: false }; + }, + branch: async (entryId) => { + const result = await this.branch(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await this.navigateTree(targetId, { summarize: options?.summarize }); + return { cancelled: result.cancelled }; + }, + }; + } + /** * Try to execute a custom command. Returns the prompt string if found, null otherwise. * If the command returns void, returns empty string to indicate it was handled. */ private async _tryExecuteCustomCommand(text: string): Promise { if (this._customCommands.length === 0) return null; - if (!this._hookRunner) return null; // Need hook runner for command context // Parse command name and args const spaceIndex = text.indexOf(" "); @@ -717,8 +872,12 @@ export class AgentSession { const loaded = this._customCommands.find((c) => c.command.name === commandName); if (!loaded) return null; - // Get command context from hook runner (includes session control methods) - const ctx = this._hookRunner.createCommandContext(); + // Get command context from extension runner (includes session control methods) + const baseCtx = this._createCommandContext(); + const ctx = { + ...baseCtx, + hasQueuedMessages: baseCtx.hasPendingMessages, + } as HookCommandContext; try { const args = parseCommandArgs(argsString); @@ -727,23 +886,51 @@ export class AgentSession { // If void/undefined, command handled everything return result ?? ""; } catch (err) { - // Emit error via hook runner - this._hookRunner.emitError({ - hookPath: `custom-command:${commandName}`, - event: "command", - error: err instanceof Error ? err.message : String(err), - }); + // Emit error via extension runner + if (this._extensionRunner) { + this._extensionRunner.emitError({ + extensionPath: `custom-command:${commandName}`, + event: "command", + error: err instanceof Error ? err.message : String(err), + }); + } else { + const message = err instanceof Error ? err.message : String(err); + console.error(`Custom command "${commandName}" failed: ${message}`); + } return ""; // Command was handled (with error) } } /** - * Queue a message to be sent after the current response completes. - * Use when agent is currently streaming. + * Queue a steering message to interrupt the agent mid-run. */ - async queueMessage(text: string): Promise { - this._queuedMessages.push(text); - await this.agent.queueMessage({ + async steer(text: string): Promise { + if (text.startsWith("/")) { + this._throwIfExtensionCommand(text); + } + + const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); + await this._queueSteer(expandedText); + } + + /** + * Queue a follow-up message to process after the agent would otherwise stop. + */ + async followUp(text: string): Promise { + if (text.startsWith("/")) { + this._throwIfExtensionCommand(text); + } + + const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); + await this._queueFollowUp(expandedText); + } + + /** + * Internal: Queue a steering message (already expanded, no extension command check). + */ + private async _queueSteer(text: string): Promise { + this._steeringMessages.push(text); + this.agent.steer({ role: "user", content: [{ type: "text", text }], timestamp: Date.now(), @@ -751,65 +938,103 @@ export class AgentSession { } /** - * Send a hook message to the session. Creates a CustomMessageEntry. + * Internal: Queue a follow-up message (already expanded, no extension command check). + */ + private async _queueFollowUp(text: string): Promise { + this._followUpMessages.push(text); + this.agent.followUp({ + role: "user", + content: [{ type: "text", text }], + timestamp: Date.now(), + }); + } + + /** + * Throw an error if the text is an extension command. + */ + private _throwIfExtensionCommand(text: string): void { + if (!this._extensionRunner) return; + + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const command = this._extensionRunner.getCommand(commandName); + + if (command) { + throw new Error( + `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, + ); + } + } + + /** + * Send a custom message to the session. Creates a CustomMessageEntry. * * Handles three cases: - * - Streaming: queues message, processed when loop pulls from queue + * - Streaming: queue as steer/follow-up or store for next turn * - Not streaming + triggerTurn: appends to state/session, starts new turn * - Not streaming + no trigger: appends to state/session, no turn - * - * @param message Hook message with customType, content, display, details - * @param triggerTurn If true and not streaming, triggers a new LLM turn */ - async sendHookMessage( - message: Pick, "customType" | "content" | "display" | "details">, - triggerTurn?: boolean, + async sendCustomMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { - const appMessage = { - role: "hookMessage" as const, + const appMessage: CustomMessage = { + role: "custom", customType: message.customType, content: message.content, display: message.display, details: message.details, timestamp: Date.now(), - } satisfies HookMessage; + }; if (this.isStreaming) { - // Queue for processing by agent loop - await this.agent.queueMessage(appMessage); - } else if (triggerTurn) { - // Send as prompt - agent loop will emit message events - await this.agent.prompt(appMessage); - } else { - // Just append to agent state and session, no turn - this.agent.appendMessage(appMessage); - this.sessionManager.appendCustomMessageEntry( - message.customType, - message.content, - message.display, - message.details, - ); + if (options?.deliverAs === "nextTurn") { + this._pendingNextTurnMessages.push(appMessage); + return; + } + + if (options?.deliverAs === "followUp") { + this.agent.followUp(appMessage); + } else { + this.agent.steer(appMessage); + } + return; } + + if (options?.triggerTurn) { + await this.agent.prompt(appMessage); + return; + } + + this.agent.appendMessage(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); } /** * Clear queued messages and return them. * Useful for restoring to editor when user aborts. */ - clearQueue(): string[] { - const queued = [...this._queuedMessages]; - this._queuedMessages = []; - this.agent.clearMessageQueue(); - return queued; + clearQueue(): { steering: string[]; followUp: string[] } { + const steering = [...this._steeringMessages]; + const followUp = [...this._followUpMessages]; + this._steeringMessages = []; + this._followUpMessages = []; + this.agent.clearAllQueues(); + return { steering, followUp }; } - /** Number of messages currently queued */ + /** Number of pending messages (includes both steering and follow-up) */ get queuedMessageCount(): number { - return this._queuedMessages.length; + return this._steeringMessages.length + this._followUpMessages.length; } - /** Get queued messages (read-only) */ - getQueuedMessages(): readonly string[] { - return this._queuedMessages; + /** Get pending messages (read-only) */ + getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } { + return { steering: this._steeringMessages, followUp: this._followUpMessages }; } get skillsSettings(): Required | undefined { @@ -836,8 +1061,8 @@ export class AgentSession { const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "new" (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_switch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "new", })) as SessionBeforeSwitchResult | undefined; @@ -852,20 +1077,20 @@ export class AgentSession { this.agent.reset(); await this.sessionManager.flush(); this.sessionManager.newSession(options); - this._queuedMessages = []; + this._steeringMessages = []; + this._followUpMessages = []; + this._pendingNextTurnMessages = []; this._reconnectToAgent(); // Emit session_switch event with reason "new" to hooks - if (this._hookRunner) { - await this._hookRunner.emit({ + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_switch", reason: "new", previousSessionFile, }); } - // Emit session event to custom tools - await this.emitCustomToolSessionEvent("switch", previousSessionFile); return true; } @@ -910,7 +1135,7 @@ export class AgentSession { * Skips missing roles and deduplicates models. */ async cycleRoleModels(roleOrder: string[]): Promise { - const availableModels = await this._modelRegistry.getAvailable(); + const availableModels = this._modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; const currentModel = this.model; @@ -983,7 +1208,7 @@ export class AgentSession { } private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { - const availableModels = await this._modelRegistry.getAvailable(); + const availableModels = this._modelRegistry.getAvailable(); if (availableModels.length <= 1) return undefined; const currentModel = this.model; @@ -1012,7 +1237,7 @@ export class AgentSession { /** * Get all available models with valid API keys. */ - async getAvailableModels(): Promise[]> { + getAvailableModels(): Model[] { return this._modelRegistry.getAvailable(); } @@ -1075,16 +1300,25 @@ export class AgentSession { } // ========================================================================= - // Queue Mode Management + // Message Queue Mode Management // ========================================================================= /** - * Set message queue mode. + * Set steering mode. * Saves to settings. */ - setQueueMode(mode: "all" | "one-at-a-time"): void { - this.agent.setQueueMode(mode); - this.settingsManager.setQueueMode(mode); + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.agent.setSteeringMode(mode); + this.settingsManager.setSteeringMode(mode); + } + + /** + * Set follow-up mode. + * Saves to settings. + */ + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.agent.setFollowUpMode(mode); + this.settingsManager.setFollowUpMode(mode); } /** @@ -1134,10 +1368,10 @@ export class AgentSession { } let hookCompaction: CompactionResult | undefined; - let fromHook = false; + let fromExtension = false; - if (this._hookRunner?.hasHandlers("session_before_compact")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_compact")) { + const result = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, @@ -1151,7 +1385,7 @@ export class AgentSession { if (result?.compaction) { hookCompaction = result.compaction; - fromHook = true; + fromExtension = true; } } @@ -1161,7 +1395,7 @@ export class AgentSession { let details: unknown; if (hookCompaction) { - // Hook provided compaction content + // Extension provided compaction content summary = hookCompaction.summary; firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = hookCompaction.tokensBefore; @@ -1185,7 +1419,7 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1195,11 +1429,11 @@ export class AgentSession { | CompactionEntry | undefined; - if (this._hookRunner && savedCompactionEntry) { - await this._hookRunner.emit({ + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, - fromHook, + fromExtension, }); } @@ -1306,10 +1540,10 @@ export class AgentSession { } let hookCompaction: CompactionResult | undefined; - let fromHook = false; + let fromExtension = false; - if (this._hookRunner?.hasHandlers("session_before_compact")) { - const hookResult = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_compact")) { + const hookResult = (await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, @@ -1324,7 +1558,7 @@ export class AgentSession { if (hookResult?.compaction) { hookCompaction = hookResult.compaction; - fromHook = true; + fromExtension = true; } } @@ -1334,7 +1568,7 @@ export class AgentSession { let details: unknown; if (hookCompaction) { - // Hook provided compaction content + // Extension provided compaction content summary = hookCompaction.summary; firstKeptEntryId = hookCompaction.firstKeptEntryId; tokensBefore = hookCompaction.tokensBefore; @@ -1359,7 +1593,7 @@ export class AgentSession { return; } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.replaceMessages(sessionContext.messages); @@ -1369,11 +1603,11 @@ export class AgentSession { | CompactionEntry | undefined; - if (this._hookRunner && savedCompactionEntry) { - await this._hookRunner.emit({ + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, - fromHook, + fromExtension, }); } @@ -1590,8 +1824,13 @@ export class AgentSession { * Adds result to agent context and session. * @param command The bash command to execute * @param onChunk Optional streaming callback for output + * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) */ - async executeBash(command: string, onChunk?: (chunk: string) => void): Promise { + async executeBash( + command: string, + onChunk?: (chunk: string) => void, + options?: { excludeFromContext?: boolean }, + ): Promise { this._bashAbortController = new AbortController(); try { @@ -1610,6 +1849,7 @@ export class AgentSession { truncated: result.truncated, fullOutputPath: result.fullOutputPath, timestamp: Date.now(), + excludeFromContext: options?.excludeFromContext, }; // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering @@ -1679,8 +1919,8 @@ export class AgentSession { const previousSessionFile = this.sessionManager.getSessionFile(); // Emit session_before_switch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_switch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_switch", reason: "resume", targetSessionFile: sessionPath, @@ -1693,7 +1933,9 @@ export class AgentSession { this._disconnectFromAgent(); await this.abort(); - this._queuedMessages = []; + this._steeringMessages = []; + this._followUpMessages = []; + this._pendingNextTurnMessages = []; // Flush pending writes before switching await this.sessionManager.flush(); @@ -1705,17 +1947,14 @@ export class AgentSession { const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_switch event to hooks - if (this._hookRunner) { - await this._hookRunner.emit({ + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_switch", reason: "resume", previousSessionFile, }); } - // Emit session event to custom tools - await this.emitCustomToolSessionEvent("switch", previousSessionFile); - this.agent.replaceMessages(sessionContext.messages); // Restore model if saved @@ -1725,7 +1964,7 @@ export class AgentSession { if (slashIdx > 0) { const provider = defaultModelStr.slice(0, slashIdx); const modelId = defaultModelStr.slice(slashIdx + 1); - const availableModels = await this._modelRegistry.getAvailable(); + const availableModels = this._modelRegistry.getAvailable(); const match = availableModels.find((m) => m.provider === provider && m.id === modelId); if (match) { this.agent.setModel(match); @@ -1764,8 +2003,8 @@ export class AgentSession { let skipConversationRestore = false; // Emit session_before_branch event (can be cancelled) - if (this._hookRunner?.hasHandlers("session_before_branch")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_branch")) { + const result = (await this._extensionRunner.emit({ type: "session_before_branch", entryId, })) as SessionBeforeBranchResult | undefined; @@ -1776,6 +2015,9 @@ export class AgentSession { skipConversationRestore = result?.skipConversationRestore ?? false; } + // Clear pending messages (bound to old session state) + this._pendingNextTurnMessages = []; + // Flush pending writes before branching await this.sessionManager.flush(); @@ -1789,16 +2031,13 @@ export class AgentSession { const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_branch event to hooks (after branch completes) - if (this._hookRunner) { - await this._hookRunner.emit({ + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_branch", previousSessionFile, }); } - // Emit session event to custom tools (with reason "branch") - await this.emitCustomToolSessionEvent("branch", previousSessionFile); - if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); } @@ -1859,11 +2098,11 @@ export class AgentSession { // Set up abort controller for summarization this._branchSummaryAbortController = new AbortController(); let hookSummary: { summary: string; details?: unknown } | undefined; - let fromHook = false; + let fromExtension = false; // Emit session_before_tree event - if (this._hookRunner?.hasHandlers("session_before_tree")) { - const result = (await this._hookRunner.emit({ + if (this._extensionRunner?.hasHandlers("session_before_tree")) { + const result = (await this._extensionRunner.emit({ type: "session_before_tree", preparation, signal: this._branchSummaryAbortController.signal, @@ -1875,7 +2114,7 @@ export class AgentSession { if (result?.summary && options.summarize) { hookSummary = result.summary; - fromHook = true; + fromExtension = true; } } @@ -1941,7 +2180,7 @@ export class AgentSession { let summaryEntry: BranchSummaryEntry | undefined; if (summaryText) { // Create summary at target position (can be null for root) - const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromHook); + const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; } else if (newLeafId === null) { // No summary, navigating to root - reset leaf @@ -1956,19 +2195,16 @@ export class AgentSession { this.agent.replaceMessages(sessionContext.messages); // Emit session_tree event - if (this._hookRunner) { - await this._hookRunner.emit({ + if (this._extensionRunner) { + await this._extensionRunner.emit({ type: "session_tree", newLeafId: this.sessionManager.getLeafId(), oldLeafId, summaryEntry, - fromHook: summaryText ? fromHook : undefined, + fromExtension: summaryText ? fromExtension : undefined, }); } - // Emit to custom tools - await this.emitCustomToolSessionEvent("tree", this.sessionFile); - this._branchSummaryAbortController = undefined; return { editorText, cancelled: false, summaryEntry }; } @@ -2176,60 +2412,30 @@ export class AgentSession { } // ========================================================================= - // Hook System + // Extension System // ========================================================================= /** - * Check if hooks have handlers for a specific event type. + * Check if extensions have handlers for a specific event type. */ - hasHookHandlers(eventType: string): boolean { - return this._hookRunner?.hasHandlers(eventType) ?? false; + hasExtensionHandlers(eventType: string): boolean { + return this._extensionRunner?.hasHandlers(eventType) ?? false; } /** - * Get the hook runner (for setting UI context and error handlers). + * Get the extension runner (for setting UI context and error handlers). */ - get hookRunner(): HookRunner | undefined { - return this._hookRunner; + get extensionRunner(): ExtensionRunner | undefined { + return this._extensionRunner; } /** - * Get custom tools (for setting UI context in modes). + * Emit a custom tool session event (backwards compatibility for older callers). */ - get customTools(): LoadedCustomTool[] { - return this._customTools; - } - - /** - * Emit session event to all custom tools. - * Called on session switch, branch, tree navigation, and shutdown. - */ - async emitCustomToolSessionEvent( - reason: CustomToolSessionEvent["reason"], - previousSessionFile?: string | undefined, - ): Promise { - if (!this._customTools) return; - - const event: CustomToolSessionEvent = { reason, previousSessionFile }; - const ctx: CustomToolContext = { - sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, - model: this.agent.state.model, - isIdle: () => !this.isStreaming, - hasQueuedMessages: () => this.queuedMessageCount > 0, - abort: () => { - this.abort(); - }, - }; - - for (const { tool } of this._customTools) { - if (tool.onSession) { - try { - await tool.onSession(event, ctx); - } catch (err) { - logger.warn("Tool onSession error", { error: String(err) }); - } - } - } + async emitCustomToolSessionEvent(reason: "start" | "switch" | "branch" | "tree" | "shutdown"): Promise { + if (!this._extensionRunner) return; + if (reason !== "shutdown") return; + if (!this._extensionRunner.hasHandlers("session_shutdown")) return; + await this._extensionRunner.emit({ type: "session_shutdown" }); } } diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index f7e34d050..8ef89585c 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -3,7 +3,7 @@ * Handles loading, saving, and refreshing credentials from auth.json. */ -import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { getEnvApiKey, @@ -12,6 +12,7 @@ import { loginAntigravity, loginGeminiCli, loginGitHubCopilot, + loginOpenAICodex, type OAuthCredentials, type OAuthProvider, } from "@oh-my-pi/pi-ai"; @@ -46,9 +47,7 @@ export class AuthStorage { constructor( private authPath: string, private fallbackPaths: string[] = [], - ) { - this.reload(); - } + ) {} /** * Set a runtime API key override (not persisted to disk). @@ -77,7 +76,7 @@ export class AuthStorage { * Reload credentials from disk. * Checks primary path first, then fallback paths. */ - reload(): void { + async reload(): Promise { const pathsToCheck = [this.authPath, ...this.fallbackPaths]; logger.debug("AuthStorage.reload checking paths", { paths: pathsToCheck }); @@ -105,13 +104,11 @@ export class AuthStorage { /** * Save credentials to disk. */ - private save(): void { - const dir = dirname(this.authPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - Bun.write(this.authPath, JSON.stringify(this.data, null, 2)); + private async save(): Promise { + writeFileSync(this.authPath, JSON.stringify(this.data, null, 2)); chmodSync(this.authPath, 0o600); + const dir = dirname(this.authPath); + chmodSync(dir, 0o700); } /** @@ -124,17 +121,17 @@ export class AuthStorage { /** * Set credential for a provider. */ - set(provider: string, credential: AuthCredential): void { + async set(provider: string, credential: AuthCredential): Promise { this.data[provider] = credential; - this.save(); + await this.save(); } /** * Remove credential for a provider. */ - remove(provider: string): void { + async remove(provider: string): Promise { delete this.data[provider]; - this.save(); + await this.save(); } /** @@ -145,12 +142,24 @@ export class AuthStorage { } /** - * Check if credentials exist for a provider. + * Check if credentials exist for a provider in auth.json. */ has(provider: string): boolean { return provider in this.data; } + /** + * Check if any form of auth is configured for a provider. + * Unlike getApiKey(), this doesn't refresh OAuth tokens. + */ + hasAuth(provider: string): boolean { + if (this.runtimeOverrides.has(provider)) return true; + if (this.data[provider]) return true; + if (getEnvApiKey(provider)) return true; + if (this.fallbackResolver?.(provider)) return true; + return false; + } + /** * Get all credentials (for passing to getOAuthApiKey). */ @@ -191,18 +200,21 @@ export class AuthStorage { case "google-antigravity": credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress); break; + case "openai-codex": + credentials = await loginOpenAICodex(callbacks); + break; default: throw new Error(`Unknown OAuth provider: ${provider}`); } - this.set(provider, { type: "oauth", ...credentials }); + await this.set(provider, { type: "oauth", ...credentials }); } /** * Logout from a provider. */ - logout(provider: string): void { - this.remove(provider); + async logout(provider: string): Promise { + await this.remove(provider); } /** @@ -240,11 +252,11 @@ export class AuthStorage { const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds); if (result) { this.data[provider] = { type: "oauth", ...result.newCredentials }; - this.save(); + await this.save(); return result.apiKey; } } catch { - this.remove(provider); + await this.remove(provider); } } diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 2898a7ce9..2ac7cd1a1 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -14,7 +14,7 @@ import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, - createHookMessage, + createCustomMessage, } from "../messages"; import type { ReadonlySessionManager, SessionEntry } from "../session-manager"; import { estimateTokens } from "./compaction"; @@ -149,7 +149,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { return entry.message; case "custom_message": - return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); case "branch_summary": return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); @@ -186,9 +186,9 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe // First pass: collect file ops from ALL entries (even if they don't fit in token budget) // This ensures we capture cumulative file tracking from nested branch summaries - // Only extract from pi-generated summaries (fromHook !== true), not hook-generated ones + // Only extract from pi-generated summaries (fromExtension !== true), not extension-generated ones for (const entry of entries) { - if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + if (entry.type === "branch_summary" && !entry.fromExtension && entry.details) { const details = entry.details as BranchSummaryDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 13d6ff4e5..8324ea2a5 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -11,7 +11,7 @@ import { complete, completeSimple } from "@oh-my-pi/pi-ai"; import compactionSummaryPrompt from "../../prompts/compaction-summary.md" with { type: "text" }; import compactionTurnPrefixPrompt from "../../prompts/compaction-turn-prefix.md" with { type: "text" }; import compactionUpdateSummaryPrompt from "../../prompts/compaction-update-summary.md" with { type: "text" }; -import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages"; +import { convertToLlm, createBranchSummaryMessage, createCustomMessage } from "../messages"; import type { CompactionEntry, SessionEntry } from "../session-manager"; import { computeFileLists, @@ -46,7 +46,7 @@ function extractFileOperations( // Collect from previous compaction's details (if pi-generated) if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - if (!prevCompaction.fromHook && prevCompaction.details) { + if (!prevCompaction.fromExtension && prevCompaction.details) { const details = prevCompaction.details as CompactionDetails; if (Array.isArray(details.readFiles)) { for (const f of details.readFiles) fileOps.read.add(f); @@ -78,7 +78,7 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { return entry.message; } if (entry.type === "custom_message") { - return createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); } if (entry.type === "branch_summary") { return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); diff --git a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts index 5d6f6b630..42458e095 100644 --- a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts +++ b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts @@ -99,8 +99,8 @@ function formatError(err: unknown): string { return String(err); } -function pickAgent(cwd: string): AgentDefinition { - const { agents } = discoverAgents(cwd); +async function pickAgent(cwd: string): Promise { + const { agents } = await discoverAgents(cwd); // Use the bundled "task" agent as the general-purpose default. const agent = getAgent(agents, "task") ?? agents[0]; if (!agent) { @@ -256,7 +256,7 @@ async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise t.scope)); const sessionId = `parallel-${Date.now()}`; - const agent = pickAgent(ctx.cwd); + const agent = await pickAgent(ctx.cwd); const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = []; for (let i = 0; i < args.length; i++) { diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts index be62bc15e..eab3ca934 100644 --- a/packages/coding-agent/src/core/custom-tools/wrapper.ts +++ b/packages/coding-agent/src/core/custom-tools/wrapper.ts @@ -3,6 +3,7 @@ */ import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { Theme } from "../../modes/interactive/theme/theme"; import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types"; /** @@ -18,6 +19,10 @@ export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolCon hidden: tool.hidden, execute: (toolCallId, params, signal, onUpdate, context) => tool.execute(toolCallId, params, onUpdate, context ?? getContext(), signal), + renderCall: tool.renderCall ? (args, theme) => tool.renderCall?.(args, theme as Theme) : undefined, + renderResult: tool.renderResult + ? (result, options, theme) => tool.renderResult?.(result, options, theme as Theme) + : undefined, }; } diff --git a/packages/coding-agent/src/core/event-bus.ts b/packages/coding-agent/src/core/event-bus.ts new file mode 100644 index 000000000..e3fa8c206 --- /dev/null +++ b/packages/coding-agent/src/core/event-bus.ts @@ -0,0 +1,59 @@ +export interface EventBus { + emit(channel: string, data: unknown): void; + on(channel: string, handler: (data: unknown) => void): () => void; +} + +export interface EventBusController extends EventBus { + clear(): void; +} + +class SimpleEventEmitter { + private listeners = new Map void>>(); + + on(channel: string, handler: (data: unknown) => void): void { + if (!this.listeners.has(channel)) { + this.listeners.set(channel, new Set()); + } + this.listeners.get(channel)!.add(handler); + } + + off(channel: string, handler: (data: unknown) => void): void { + this.listeners.get(channel)?.delete(handler); + } + + emit(channel: string, data: unknown): void { + const handlers = this.listeners.get(channel); + if (handlers) { + for (const handler of handlers) { + handler(data); + } + } + } + + removeAllListeners(): void { + this.listeners.clear(); + } +} + +export function createEventBus(): EventBusController { + const emitter = new SimpleEventEmitter(); + return { + emit: (channel, data) => { + emitter.emit(channel, data); + }, + on: (channel, handler) => { + const safeHandler = async (data: unknown) => { + try { + await handler(data); + } catch (err) { + console.error(`Event handler error (${channel}):`, err); + } + }; + emitter.on(channel, safeHandler); + return () => emitter.off(channel, safeHandler); + }, + clear: () => { + emitter.removeAllListeners(); + }, + }; +} diff --git a/packages/coding-agent/src/core/export-html/vendor/highlight.min.js b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js new file mode 100644 index 000000000..5d699ae6a --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js @@ -0,0 +1,1213 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/packages/coding-agent/src/core/export-html/vendor/marked.min.js b/packages/coding-agent/src/core/export-html/vendor/marked.min.js new file mode 100644 index 000000000..79394fd8f --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v15.0.4 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,f=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),d=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",d).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:f,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:d,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,$=/^( {2,}|\\)\n(?!\s*$)/,R=/[\p{P}\p{S}]/u,S=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,z=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,S).getRegex(),A=r(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,R).getRegex(),_=r("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),P=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),I=r(/\\(punct)/,"gu").replace(/punct/g,R).getRegex(),L=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),B=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),C=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",B).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),E=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",E).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Z=r(/^!?\[(label)\]\[(ref)\]/).replace("label",E).replace("ref",h).getRegex(),v=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),D={_backpedal:s,anyPunctuation:I,autolink:L,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:$,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:A,emStrongRDelimAst:_,emStrongRDelimUnd:P,escape:y,link:q,nolink:v,punctuation:z,reflink:Z,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",Z).replace("nolink",v).getRegex(),tag:C,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},H=e=>G[e];function X(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,H)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,H);return e}function F(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function U(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:J(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=J(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:J(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=J(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=J(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),K(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return K(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class W{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new V,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:j.normal,inline:N.normal};this.options.pedantic?(n.block=j.pedantic,n.inline=N.pedantic):this.options.gfm&&(n.block=j.gfm,this.options.breaks?n.inline=N.breaks:n.inline=N.gfm),this.tokenizer.rules=n}static get rules(){return{block:j,inline:N}}static lex(e,t){return new W(t).lex(e)}static lexInline(e,t){return new W(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class Y{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:X(r,!0))+"
\n":"
"+(n?r:X(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+X(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${X(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=F(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=F(e);if(null===s)return X(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new Y(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new V(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ne;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ne.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return W.lex(e,t??this.defaults)}parser(e,t){return te.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?W.lex:W.lexInline,o=r.hooks?r.hooks.provideParser():e?te.parse:te.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+X(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const re=new se;function ie(e,t){return re.parse(e,t)}ie.options=ie.setOptions=function(e){return re.setOptions(e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.getDefaults=t,ie.defaults=e.defaults,ie.use=function(...e){return re.use(...e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.walkTokens=function(e,t){return re.walkTokens(e,t)},ie.parseInline=re.parseInline,ie.Parser=te,ie.parser=te.parse,ie.Renderer=Y,ie.TextRenderer=ee,ie.Lexer=W,ie.lexer=W.lex,ie.Tokenizer=V,ie.Hooks=ne,ie.parse=ie;const le=ie.options,oe=ie.setOptions,ae=ie.use,ce=ie.walkTokens,he=ie.parseInline,pe=ie,ue=te.parse,ge=W.lex;e.Hooks=ne,e.Lexer=W,e.Marked=se,e.Parser=te,e.Renderer=Y,e.TextRenderer=ee,e.Tokenizer=V,e.getDefaults=t,e.lexer=ge,e.marked=ie,e.options=le,e.parse=pe,e.parseInline=he,e.parser=ue,e.setOptions=oe,e.use=ae,e.walkTokens=ce})); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts new file mode 100644 index 000000000..0ce363e25 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -0,0 +1,100 @@ +/** + * Extension system for lifecycle events and custom tools. + */ + +export { discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader"; +export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner"; +export { ExtensionRunner } from "./runner"; +export type { + AgentEndEvent, + AgentStartEvent, + // Re-exports + AgentToolResult, + AgentToolUpdateCallback, + AppendEntryHandler, + BashToolResultEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + // Events - Agent + ContextEvent, + // Event Results + ContextEventResult, + CustomToolResultEvent, + EditToolResultEvent, + ExecOptions, + ExecResult, + // API + ExtensionAPI, + ExtensionCommandContext, + // Context + ExtensionContext, + // Errors + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + ExtensionShortcut, + ExtensionUIContext, + FindToolResultEvent, + GetActiveToolsHandler, + GetAllToolsHandler, + GrepToolResultEvent, + LoadExtensionsResult, + // Loaded Extension + LoadedExtension, + LsToolResultEvent, + // Message Rendering + MessageRenderer, + MessageRenderOptions, + ReadToolResultEvent, + // Commands + RegisteredCommand, + RegisteredTool, + SendMessageHandler, + SessionBeforeBranchEvent, + SessionBeforeBranchResult, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionBranchEvent, + SessionCompactEvent, + SessionEvent, + SessionShutdownEvent, + // Events - Session + SessionStartEvent, + SessionSwitchEvent, + SessionTreeEvent, + SetActiveToolsHandler, + // Events - Tool + ToolCallEvent, + ToolCallEventResult, + // Tools + ToolDefinition, + ToolRenderResultOptions, + ToolResultEvent, + ToolResultEventResult, + TreePreparation, + TurnEndEvent, + TurnStartEvent, + WriteToolResultEvent, +} from "./types"; +// Type guards +export { + isBashToolResult, + isEditToolResult, + isFindToolResult, + isGrepToolResult, + isLsToolResult, + isReadToolResult, + isWriteToolResult, +} from "./types"; +export { + wrapRegisteredTool, + wrapRegisteredTools, + wrapToolsWithExtensions, + wrapToolWithExtensions, +} from "./wrapper"; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts new file mode 100644 index 000000000..e588dbd5b --- /dev/null +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -0,0 +1,501 @@ +/** + * Extension loader - loads TypeScript extension modules using native Bun import. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import * as path from "node:path"; +import type { KeyId } from "@oh-my-pi/pi-tui"; +import * as TypeBox from "@sinclair/typebox"; +import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; +import { loadSync } from "../../discovery"; +import { getExtensionNameFromPath } from "../../discovery/helpers"; +import * as piCodingAgent from "../../index"; +import { theme } from "../../modes/interactive/theme/theme"; +import { createEventBus, type EventBus } from "../event-bus"; +import type { ExecOptions } from "../exec"; +import { execCommand } from "../exec"; +import { logger } from "../logger"; +import type { + AppendEntryHandler, + ExtensionAPI, + ExtensionContext, + ExtensionFactory, + ExtensionFlag, + ExtensionShortcut, + ExtensionUIContext, + GetActiveToolsHandler, + GetAllToolsHandler, + LoadExtensionsResult, + LoadedExtension, + MessageRenderer, + RegisteredCommand, + RegisteredTool, + SendMessageHandler, + SetActiveToolsHandler, + ToolDefinition, +} from "./types"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function expandPath(p: string): string { + const normalized = normalizeUnicodeSpaces(p); + if (normalized.startsWith("~/")) { + return path.join(homedir(), normalized.slice(2)); + } + if (normalized.startsWith("~")) { + return path.join(homedir(), normalized.slice(1)); + } + return normalized; +} + +function resolvePath(extPath: string, cwd: string): string { + const expanded = expandPath(extPath); + if (path.isAbsolute(expanded)) { + return expanded; + } + return path.resolve(cwd, expanded); +} + +function createNoOpUIContext(): ExtensionUIContext { + return { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return theme; + }, + }; +} + +type HandlerFn = (...args: unknown[]) => Promise; + +function createExtensionAPI( + handlers: Map, + tools: Map, + cwd: string, + extensionPath: string, + eventBus: EventBus, + _sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, +): { + api: ExtensionAPI; + messageRenderers: Map; + commands: Map; + flags: Map; + flagValues: Map; + shortcuts: Map; + setSendMessageHandler: (handler: SendMessageHandler) => void; + setAppendEntryHandler: (handler: AppendEntryHandler) => void; + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; + setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; + setFlagValue: (name: string, value: boolean | string) => void; +} { + let sendMessageHandler: SendMessageHandler = () => {}; + let appendEntryHandler: AppendEntryHandler = () => {}; + let getActiveToolsHandler: GetActiveToolsHandler = () => []; + let getAllToolsHandler: GetAllToolsHandler = () => []; + let setActiveToolsHandler: SetActiveToolsHandler = () => {}; + + const messageRenderers = new Map(); + const commands = new Map(); + const flags = new Map(); + const flagValues = new Map(); + const shortcuts = new Map(); + + const api = { + logger, + typebox: TypeBox, + pi: piCodingAgent, + + on(event: string, handler: HandlerFn): void { + const list = handlers.get(event) ?? []; + list.push(handler); + handlers.set(event, list); + }, + + registerTool(tool: ToolDefinition): void { + tools.set(tool.name, { + definition: tool, + extensionPath, + }); + }, + + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { + commands.set(name, { name, ...options }); + }, + + registerShortcut( + shortcut: KeyId, + options: { + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + }, + ): void { + shortcuts.set(shortcut, { shortcut, extensionPath, ...options }); + }, + + registerFlag( + name: string, + options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, + ): void { + flags.set(name, { name, extensionPath, ...options }); + if (options.default !== undefined) { + flagValues.set(name, options.default); + } + }, + + getFlag(name: string): boolean | string | undefined { + return flagValues.get(name); + }, + + registerMessageRenderer(customType: string, renderer: MessageRenderer): void { + messageRenderers.set(customType, renderer as MessageRenderer); + }, + + sendMessage(message, options): void { + sendMessageHandler(message, options); + }, + + appendEntry(customType: string, data?: unknown): void { + appendEntryHandler(customType, data); + }, + + exec(command: string, args: string[], options?: ExecOptions) { + return execCommand(command, args, options?.cwd ?? cwd, options); + }, + + getActiveTools(): string[] { + return getActiveToolsHandler(); + }, + + getAllTools(): string[] { + return getAllToolsHandler(); + }, + + setActiveTools(toolNames: string[]): void { + setActiveToolsHandler(toolNames); + }, + + events: eventBus, + } as ExtensionAPI; + + return { + api, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler: (handler: SendMessageHandler) => { + sendMessageHandler = handler; + }, + setAppendEntryHandler: (handler: AppendEntryHandler) => { + appendEntryHandler = handler; + }, + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => { + getActiveToolsHandler = handler; + }, + setGetAllToolsHandler: (handler: GetAllToolsHandler) => { + getAllToolsHandler = handler; + }, + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => { + setActiveToolsHandler = handler; + }, + setFlagValue: (name: string, value: boolean | string) => { + flagValues.set(name, value); + }, + }; +} + +async function loadExtension( + extensionPath: string, + cwd: string, + eventBus: EventBus, + sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, +): Promise<{ extension: LoadedExtension | null; error: string | null }> { + const resolvedPath = resolvePath(extensionPath, cwd); + + try { + const module = await import(resolvedPath); + const factory = (module.default ?? module) as ExtensionFactory; + + if (typeof factory !== "function") { + return { extension: null, error: "Extension must export a default function" }; + } + + const handlers = new Map(); + const tools = new Map(); + const { + api, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); + + factory(api); + + return { + extension: { + path: extensionPath, + resolvedPath, + handlers, + tools, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + }, + error: null, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { extension: null, error: `Failed to load extension: ${message}` }; + } +} + +/** + * Create a LoadedExtension from an inline factory function. + */ +export function loadExtensionFromFactory( + factory: ExtensionFactory, + cwd: string, + eventBus: EventBus, + sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, + name = "", +): LoadedExtension { + const handlers = new Map(); + const tools = new Map(); + const { + api, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + } = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI); + + factory(api); + + return { + path: name, + resolvedPath: name, + handlers, + tools, + messageRenderers, + commands, + flags, + flagValues, + shortcuts, + setSendMessageHandler, + setAppendEntryHandler, + setGetActiveToolsHandler, + setGetAllToolsHandler, + setSetActiveToolsHandler, + setFlagValue, + }; +} + +/** + * Load extensions from paths. + */ +export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { + const extensions: LoadedExtension[] = []; + const errors: Array<{ path: string; error: string }> = []; + const resolvedEventBus = eventBus ?? createEventBus(); + const sharedUI = { ui: createNoOpUIContext(), hasUI: false }; + + for (const extPath of paths) { + const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, sharedUI); + + if (error) { + errors.push({ path: extPath, error }); + continue; + } + + if (extension) { + extensions.push(extension); + } + } + + return { + extensions, + errors, + setUIContext(uiContext, hasUI) { + sharedUI.ui = uiContext; + sharedUI.hasUI = hasUI; + }, + }; +} + +interface ExtensionManifest { + extensions?: string[]; + themes?: string[]; + skills?: string[]; +} + +function readExtensionManifest(packageJsonPath: string): ExtensionManifest | null { + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { omp?: ExtensionManifest; pi?: ExtensionManifest }; + const manifest = pkg.omp ?? pkg.pi; + if (manifest && typeof manifest === "object") { + return manifest; + } + return null; + } catch { + return null; + } +} + +function isExtensionFile(name: string): boolean { + return name.endsWith(".ts") || name.endsWith(".js"); +} + +/** + * Discover extensions in a directory. + * + * Discovery rules: + * 1. Direct files: `extensions/*.ts` or `*.js` → load + * 2. Subdirectory with index: `extensions//index.ts` or `index.js` → load + * 3. Subdirectory with package.json: `extensions//package.json` with "omp"/"pi" field → load declared paths + * + * No recursion beyond one level. Complex packages must use package.json manifest. + */ +function discoverExtensionsInDir(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + + const discovered: string[] = []; + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + // 1. Direct files: *.ts or *.js + if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { + discovered.push(entryPath); + continue; + } + + // 2 & 3. Subdirectories + if (entry.isDirectory() || entry.isSymbolicLink()) { + // Check for package.json with "omp"/"pi" field first + const packageJsonPath = path.join(entryPath, "package.json"); + if (existsSync(packageJsonPath)) { + const manifest = readExtensionManifest(packageJsonPath); + if (manifest?.extensions) { + // Load paths declared in manifest (relative to package.json dir) + for (const extPath of manifest.extensions) { + const resolvedExtPath = path.resolve(entryPath, extPath); + if (existsSync(resolvedExtPath)) { + discovered.push(resolvedExtPath); + } + } + continue; + } + } + + // Check for index.ts or index.js + const indexTs = path.join(entryPath, "index.ts"); + const indexJs = path.join(entryPath, "index.js"); + if (existsSync(indexTs)) { + discovered.push(indexTs); + } else if (existsSync(indexJs)) { + discovered.push(indexJs); + } + } + } + } catch { + return []; + } + + return discovered; +} + +/** + * Discover and load extensions from standard locations. + */ +export async function discoverAndLoadExtensions( + configuredPaths: string[], + cwd: string, + eventBus?: EventBus, + disabledExtensionIds: string[] = [], +): Promise { + const allPaths: string[] = []; + const seen = new Set(); + const disabled = new Set(disabledExtensionIds); + + const isDisabledName = (name: string): boolean => disabled.has(`extension-module:${name}`); + + const addPath = (extPath: string): void => { + const resolved = path.resolve(extPath); + if (!seen.has(resolved)) { + seen.add(resolved); + allPaths.push(extPath); + } + }; + + const addPaths = (paths: string[]) => { + for (const extPath of paths) { + if (isDisabledName(getExtensionNameFromPath(extPath))) continue; + addPath(extPath); + } + }; + + // 1. Discover extension modules via capability API (native .omp/.pi only) + const discovered = loadSync(extensionModuleCapability.id, { cwd }); + for (const ext of discovered.items) { + if (ext._source.provider !== "native") continue; + if (isDisabledName(ext.name)) continue; + addPath(ext.path); + } + + // 2. Explicitly configured paths + for (const configuredPath of configuredPaths) { + const resolved = resolvePath(configuredPath, cwd); + if (existsSync(resolved) && statSync(resolved).isDirectory()) { + addPaths(discoverExtensionsInDir(resolved)); + } else { + addPath(resolved); + } + } + + return loadExtensions(allPaths, cwd, eventBus); +} diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts new file mode 100644 index 000000000..e8ff61a61 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -0,0 +1,477 @@ +/** + * Extension runner - executes extensions and manages their lifecycle. + */ + +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent, Model } from "@oh-my-pi/pi-ai"; +import type { KeyId } from "@oh-my-pi/pi-tui"; +import { theme } from "../../modes/interactive/theme/theme"; +import type { ModelRegistry } from "../model-registry"; +import type { SessionManager } from "../session-manager"; +import type { + AppendEntryHandler, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + ContextEvent, + ContextEventResult, + ExtensionCommandContext, + ExtensionContext, + ExtensionError, + ExtensionEvent, + ExtensionFlag, + ExtensionShortcut, + ExtensionUIContext, + GetActiveToolsHandler, + GetAllToolsHandler, + LoadedExtension, + MessageRenderer, + RegisteredCommand, + RegisteredTool, + SendMessageHandler, + SessionBeforeCompactResult, + SessionBeforeTreeResult, + SetActiveToolsHandler, + ToolCallEvent, + ToolCallEventResult, + ToolResultEventResult, +} from "./types"; + +/** Combined result from all before_agent_start handlers */ +interface BeforeAgentStartCombinedResult { + messages?: NonNullable[]; + systemPromptAppend?: string; +} + +export type ExtensionErrorListener = (error: ExtensionError) => void; + +export type NewSessionHandler = (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; +}) => Promise<{ cancelled: boolean }>; + +export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>; + +export type NavigateTreeHandler = ( + targetId: string, + options?: { summarize?: boolean }, +) => Promise<{ cancelled: boolean }>; + +const noOpUIContext: ExtensionUIContext = { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return theme; + }, +}; + +export class ExtensionRunner { + private extensions: LoadedExtension[]; + private uiContext: ExtensionUIContext; + private hasUI: boolean; + private cwd: string; + private sessionManager: SessionManager; + private modelRegistry: ModelRegistry; + private errorListeners: Set = new Set(); + private getModel: () => Model | undefined = () => undefined; + private isIdleFn: () => boolean = () => true; + private waitForIdleFn: () => Promise = async () => {}; + private abortFn: () => void = () => {}; + private hasPendingMessagesFn: () => boolean = () => false; + private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + private branchHandler: BranchHandler = async () => ({ cancelled: false }); + private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + + constructor( + extensions: LoadedExtension[], + cwd: string, + sessionManager: SessionManager, + modelRegistry: ModelRegistry, + ) { + this.extensions = extensions; + this.uiContext = noOpUIContext; + this.hasUI = false; + this.cwd = cwd; + this.sessionManager = sessionManager; + this.modelRegistry = modelRegistry; + } + + initialize(options: { + getModel: () => Model | undefined; + sendMessageHandler: SendMessageHandler; + appendEntryHandler: AppendEntryHandler; + getActiveToolsHandler: GetActiveToolsHandler; + getAllToolsHandler: GetAllToolsHandler; + setActiveToolsHandler: SetActiveToolsHandler; + newSessionHandler?: NewSessionHandler; + branchHandler?: BranchHandler; + navigateTreeHandler?: NavigateTreeHandler; + isIdle?: () => boolean; + waitForIdle?: () => Promise; + abort?: () => void; + hasPendingMessages?: () => boolean; + uiContext?: ExtensionUIContext; + hasUI?: boolean; + }): void { + this.getModel = options.getModel; + this.isIdleFn = options.isIdle ?? (() => true); + this.waitForIdleFn = options.waitForIdle ?? (async () => {}); + this.abortFn = options.abort ?? (() => {}); + this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false); + + if (options.newSessionHandler) { + this.newSessionHandler = options.newSessionHandler; + } + if (options.branchHandler) { + this.branchHandler = options.branchHandler; + } + if (options.navigateTreeHandler) { + this.navigateTreeHandler = options.navigateTreeHandler; + } + + for (const ext of this.extensions) { + ext.setSendMessageHandler(options.sendMessageHandler); + ext.setAppendEntryHandler(options.appendEntryHandler); + ext.setGetActiveToolsHandler(options.getActiveToolsHandler); + ext.setGetAllToolsHandler(options.getAllToolsHandler); + ext.setSetActiveToolsHandler(options.setActiveToolsHandler); + } + + this.uiContext = options.uiContext ?? noOpUIContext; + this.hasUI = options.hasUI ?? false; + } + + getUIContext(): ExtensionUIContext | null { + return this.uiContext; + } + + getHasUI(): boolean { + return this.hasUI; + } + + getExtensionPaths(): string[] { + return this.extensions.map((e) => e.path); + } + + /** Get all registered tools from all extensions. */ + getAllRegisteredTools(): RegisteredTool[] { + const tools: RegisteredTool[] = []; + for (const ext of this.extensions) { + for (const tool of ext.tools.values()) { + tools.push(tool); + } + } + return tools; + } + + getFlags(): Map { + const allFlags = new Map(); + for (const ext of this.extensions) { + for (const [name, flag] of ext.flags) { + allFlags.set(name, flag); + } + } + return allFlags; + } + + setFlagValue(name: string, value: boolean | string): void { + for (const ext of this.extensions) { + if (ext.flags.has(name)) { + ext.setFlagValue(name, value); + } + } + } + + private static readonly RESERVED_SHORTCUTS = new Set([ + "ctrl+c", + "ctrl+d", + "ctrl+z", + "ctrl+k", + "ctrl+p", + "ctrl+l", + "ctrl+o", + "ctrl+t", + "ctrl+g", + "shift+tab", + "shift+ctrl+p", + "alt+enter", + "escape", + "enter", + ]); + + getShortcuts(): Map { + const allShortcuts = new Map(); + for (const ext of this.extensions) { + for (const [key, shortcut] of ext.shortcuts) { + const normalizedKey = key.toLowerCase() as KeyId; + + if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { + console.warn( + `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, + ); + continue; + } + + const existing = allShortcuts.get(normalizedKey); + if (existing) { + console.warn( + `Extension shortcut conflict: '${key}' registered by both ${existing.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + ); + } + allShortcuts.set(normalizedKey, shortcut); + } + } + return allShortcuts; + } + + onError(listener: ExtensionErrorListener): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + emitError(error: ExtensionError): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + hasHandlers(eventType: string): boolean { + for (const ext of this.extensions) { + const handlers = ext.handlers.get(eventType); + if (handlers && handlers.length > 0) { + return true; + } + } + return false; + } + + getMessageRenderer(customType: string): MessageRenderer | undefined { + for (const ext of this.extensions) { + const renderer = ext.messageRenderers.get(customType); + if (renderer) { + return renderer; + } + } + return undefined; + } + + getRegisteredCommands(): RegisteredCommand[] { + const commands: RegisteredCommand[] = []; + for (const ext of this.extensions) { + for (const command of ext.commands.values()) { + commands.push(command); + } + } + return commands; + } + + getCommand(name: string): RegisteredCommand | undefined { + for (const ext of this.extensions) { + const command = ext.commands.get(name); + if (command) { + return command; + } + } + return undefined; + } + + private createContext(): ExtensionContext { + return { + ui: this.uiContext, + hasUI: this.hasUI, + cwd: this.cwd, + sessionManager: this.sessionManager, + modelRegistry: this.modelRegistry, + model: this.getModel(), + isIdle: () => this.isIdleFn(), + abort: () => this.abortFn(), + hasPendingMessages: () => this.hasPendingMessagesFn(), + hasQueuedMessages: () => this.hasPendingMessagesFn(), + }; + } + + createCommandContext(): ExtensionCommandContext { + return { + ...this.createContext(), + waitForIdle: () => this.waitForIdleFn(), + newSession: (options) => this.newSessionHandler(options), + branch: (entryId) => this.branchHandler(entryId), + navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), + }; + } + + private isSessionBeforeEvent( + type: string, + ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" { + return ( + type === "session_before_switch" || + type === "session_before_branch" || + type === "session_before_compact" || + type === "session_before_tree" + ); + } + + async emit( + event: ExtensionEvent, + ): Promise { + const ctx = this.createContext(); + let result: SessionBeforeCompactResult | SessionBeforeTreeResult | ToolResultEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get(event.type); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + + if (this.isSessionBeforeEvent(event.type) && handlerResult) { + result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; + if (result.cancel) { + return result; + } + } + + if (event.type === "tool_result" && handlerResult) { + result = handlerResult as ToolResultEventResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: event.type, + error: message, + stack, + }); + } + } + } + + return result; + } + + async emitToolCall(event: ToolCallEvent): Promise { + const ctx = this.createContext(); + let result: ToolCallEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("tool_call"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + result = handlerResult as ToolCallEventResult; + if (result.block) { + return result; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "tool_call", + error: message, + stack, + }); + return { block: true, reason: `Extension ${ext.path} failed: ${message}` }; + } + } + } + + return result; + } + + async emitContext(messages: AgentMessage[]): Promise { + const ctx = this.createContext(); + let currentMessages = structuredClone(messages); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("context"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: ContextEvent = { type: "context", messages: currentMessages }; + const handlerResult = await handler(event, ctx); + + if (handlerResult && (handlerResult as ContextEventResult).messages) { + currentMessages = (handlerResult as ContextEventResult).messages!; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "context", + error: message, + stack, + }); + } + } + } + + return currentMessages; + } + + async emitBeforeAgentStart( + prompt: string, + images?: ImageContent[], + ): Promise { + const ctx = this.createContext(); + const messages: NonNullable[] = []; + const systemPromptAppends: string[] = []; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_agent_start"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + const result = handlerResult as BeforeAgentStartEventResult; + if (result.message) { + messages.push(result.message); + } + if (result.systemPromptAppend) { + systemPromptAppends.push(result.systemPromptAppend); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_agent_start", + error: message, + stack, + }); + } + } + } + + if (messages.length > 0 || systemPromptAppends.length > 0) { + return { + messages: messages.length > 0 ? messages : undefined, + systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined, + }; + } + + return undefined; + } +} diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts new file mode 100644 index 000000000..594c644ee --- /dev/null +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -0,0 +1,712 @@ +/** + * Extension system types. + * + * Extensions are TypeScript modules that can: + * - Subscribe to agent lifecycle events + * - Register LLM-callable tools + * - Register commands, keyboard shortcuts, and CLI flags + * - Interact with the user via UI primitives + */ + +import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import type { Component, KeyId, TUI } from "@oh-my-pi/pi-tui"; +import type { Static, TSchema } from "@sinclair/typebox"; +import type { Theme } from "../../modes/interactive/theme/theme"; +import type { CompactionPreparation, CompactionResult } from "../compaction"; +import type { EventBus } from "../event-bus"; +import type { ExecOptions, ExecResult } from "../exec"; +import type { CustomMessage } from "../messages"; +import type { ModelRegistry } from "../model-registry"; +import type { + BranchSummaryEntry, + CompactionEntry, + ReadonlySessionManager, + SessionEntry, + SessionManager, +} from "../session-manager"; +import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools"; +import type { EditToolDetails } from "../tools/edit"; + +export type { ExecOptions, ExecResult } from "../exec"; +export type { AgentToolResult, AgentToolUpdateCallback }; + +// ============================================================================ +// UI Context +// ============================================================================ + +/** + * UI context for extensions to request interactive UI. + * Each mode (interactive, RPC, print) provides its own implementation. + */ +export interface ExtensionUIContext { + /** Show a selector and return the user's choice. */ + select(title: string, options: string[]): Promise; + + /** Show a confirmation dialog. */ + confirm(title: string, message: string): Promise; + + /** Show a text input dialog. */ + input(title: string, placeholder?: string): Promise; + + /** Show a notification to the user. */ + notify(message: string, type?: "info" | "warning" | "error"): void; + + /** Set status text in the footer/status bar. Pass undefined to clear. */ + setStatus(key: string, text: string | undefined): void; + + /** Set a widget to display above the editor. Accepts string array or component factory. */ + setWidget(key: string, content: string[] | undefined): void; + setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + + /** Set the terminal window/tab title. */ + setTitle(title: string): void; + + /** Show a custom component with keyboard focus. */ + custom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise; + + /** Set the text in the core input editor. */ + setEditorText(text: string): void; + + /** Get the current text from the core input editor. */ + getEditorText(): string; + + /** Show a multi-line editor for text editing. */ + editor(title: string, prefill?: string): Promise; + + /** Get the current theme for styling. */ + readonly theme: Theme; +} + +// ============================================================================ +// Extension Context +// ============================================================================ + +/** + * Context passed to extension event handlers. + */ +export interface ExtensionContext { + /** UI methods for user interaction */ + ui: ExtensionUIContext; + /** Whether UI is available (false in print/RPC mode) */ + hasUI: boolean; + /** Current working directory */ + cwd: string; + /** Session manager (read-only) */ + sessionManager: ReadonlySessionManager; + /** Model registry for API key resolution */ + modelRegistry: ModelRegistry; + /** Current model (may be undefined) */ + model: Model | undefined; + /** Whether the agent is idle (not streaming) */ + isIdle(): boolean; + /** Abort the current agent operation */ + abort(): void; + /** Whether there are queued messages waiting */ + hasPendingMessages(): boolean; + /** @deprecated Use hasPendingMessages() instead */ + hasQueuedMessages(): boolean; +} + +/** + * Extended context for command handlers. + * Includes session control methods only safe in user-initiated commands. + */ +export interface ExtensionCommandContext extends ExtensionContext { + /** Wait for the agent to finish streaming */ + waitForIdle(): Promise; + + /** Start a new session, optionally with initialization. */ + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + }): Promise<{ cancelled: boolean }>; + + /** Branch from a specific entry, creating a new session file. */ + branch(entryId: string): Promise<{ cancelled: boolean }>; + + /** Navigate to a different point in the session tree. */ + navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; +} + +// ============================================================================ +// Tool Types +// ============================================================================ + +/** Rendering options for tool results */ +export interface ToolRenderResultOptions { + /** Whether the result view is expanded */ + expanded: boolean; + /** Whether this is a partial/streaming result */ + isPartial: boolean; + /** Current spinner frame index for animated elements (optional) */ + spinnerFrame?: number; +} + +/** Session event for tool onSession lifecycle */ +export interface ToolSessionEvent { + /** Reason for the session event */ + reason: "start" | "switch" | "branch" | "tree" | "shutdown"; + /** Previous session file path, or undefined for "start" and "shutdown" */ + previousSessionFile: string | undefined; +} + +/** + * Tool definition for registerTool(). + */ +export interface ToolDefinition { + /** Tool name (used in LLM tool calls) */ + name: string; + /** Human-readable label for UI */ + label: string; + /** Description for LLM */ + description: string; + /** Parameter schema (TypeBox) */ + parameters: TParams; + /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */ + hidden?: boolean; + + /** Execute the tool. */ + execute( + toolCallId: string, + params: Static, + onUpdate: AgentToolUpdateCallback | undefined, + ctx: ExtensionContext, + signal?: AbortSignal, + ): Promise>; + + /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ + onSession?: (event: ToolSessionEvent, ctx: ExtensionContext) => void | Promise; + + /** Custom rendering for tool call display */ + renderCall?: (args: Static, theme: Theme) => Component; + + /** Custom rendering for tool result display */ + renderResult?: (result: AgentToolResult, options: ToolRenderResultOptions, theme: Theme) => Component; +} + +// ============================================================================ +// Session Events +// ============================================================================ + +/** Fired on initial session load */ +export interface SessionStartEvent { + type: "session_start"; +} + +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + reason: "new" | "resume"; + targetSessionFile?: string; +} + +/** Fired after switching to another session */ +export interface SessionSwitchEvent { + type: "session_switch"; + reason: "new" | "resume"; + previousSessionFile: string | undefined; +} + +/** Fired before branching a session (can be cancelled) */ +export interface SessionBeforeBranchEvent { + type: "session_before_branch"; + entryId: string; +} + +/** Fired after branching a session */ +export interface SessionBranchEvent { + type: "session_branch"; + previousSessionFile: string | undefined; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromExtension: boolean; +} + +/** Fired on process exit */ +export interface SessionShutdownEvent { + type: "session_shutdown"; +} + +/** Preparation data for tree navigation */ +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionEntry[]; + userWantsSummary: boolean; +} + +/** Fired before navigating in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +/** Fired after navigating in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromExtension?: boolean; +} + +export type SessionEvent = + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionSwitchEvent + | SessionBeforeBranchEvent + | SessionBranchEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; + +// ============================================================================ +// Agent Events +// ============================================================================ + +/** Fired before each LLM call. Can modify messages. */ +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +/** Fired after user submits prompt but before agent loop. */ +export interface BeforeAgentStartEvent { + type: "before_agent_start"; + prompt: string; + images?: ImageContent[]; +} + +/** Fired when an agent loop starts */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** Fired when an agent loop ends */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AgentMessage[]; +} + +/** Fired at the start of each turn */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** Fired at the end of each turn */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AgentMessage; + toolResults: ToolResultMessage[]; +} + +// ============================================================================ +// Tool Events +// ============================================================================ + +/** Fired before a tool executes. Can block. */ +export interface ToolCallEvent { + type: "tool_call"; + toolName: string; + toolCallId: string; + input: Record; +} + +interface ToolResultEventBase { + type: "tool_result"; + toolCallId: string; + input: Record; + content: (TextContent | ImageContent)[]; + isError: boolean; +} + +export interface BashToolResultEvent extends ToolResultEventBase { + toolName: "bash"; + details: BashToolDetails | undefined; +} + +export interface ReadToolResultEvent extends ToolResultEventBase { + toolName: "read"; + details: ReadToolDetails | undefined; +} + +export interface EditToolResultEvent extends ToolResultEventBase { + toolName: "edit"; + details: EditToolDetails | undefined; +} + +export interface WriteToolResultEvent extends ToolResultEventBase { + toolName: "write"; + details: undefined; +} + +export interface GrepToolResultEvent extends ToolResultEventBase { + toolName: "grep"; + details: GrepToolDetails | undefined; +} + +export interface FindToolResultEvent extends ToolResultEventBase { + toolName: "find"; + details: FindToolDetails | undefined; +} + +export interface LsToolResultEvent extends ToolResultEventBase { + toolName: "ls"; + details: LsToolDetails | undefined; +} + +export interface CustomToolResultEvent extends ToolResultEventBase { + toolName: string; + details: unknown; +} + +/** Fired after a tool executes. Can modify result. */ +export type ToolResultEvent = + | BashToolResultEvent + | ReadToolResultEvent + | EditToolResultEvent + | WriteToolResultEvent + | GrepToolResultEvent + | FindToolResultEvent + | LsToolResultEvent + | CustomToolResultEvent; + +// Type guards +export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { + return e.toolName === "bash"; +} +export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { + return e.toolName === "read"; +} +export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent { + return e.toolName === "edit"; +} +export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent { + return e.toolName === "write"; +} +export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent { + return e.toolName === "grep"; +} +export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent { + return e.toolName === "find"; +} +export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { + return e.toolName === "ls"; +} + +/** Union of all event types */ +export type ExtensionEvent = + | SessionEvent + | ContextEvent + | BeforeAgentStartEvent + | AgentStartEvent + | AgentEndEvent + | TurnStartEvent + | TurnEndEvent + | ToolCallEvent + | ToolResultEvent; + +// ============================================================================ +// Event Results +// ============================================================================ + +export interface ContextEventResult { + messages?: AgentMessage[]; +} + +export interface ToolCallEventResult { + block?: boolean; + reason?: string; +} + +export interface ToolResultEventResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; +} + +export interface BeforeAgentStartEventResult { + message?: Pick; + systemPromptAppend?: string; +} + +export interface SessionBeforeSwitchResult { + cancel?: boolean; +} + +export interface SessionBeforeBranchResult { + cancel?: boolean; + skipConversationRestore?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactionResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { + summary: string; + details?: unknown; + }; +} + +// ============================================================================ +// Message Rendering +// ============================================================================ + +export interface MessageRenderOptions { + expanded: boolean; +} + +export type MessageRenderer = ( + message: CustomMessage, + options: MessageRenderOptions, + theme: Theme, +) => Component | undefined; + +// ============================================================================ +// Command Registration +// ============================================================================ + +export interface RegisteredCommand { + name: string; + description?: string; + handler: (args: string, ctx: ExtensionCommandContext) => Promise; +} + +// ============================================================================ +// Extension API +// ============================================================================ + +/** Handler function type for events */ +// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements +export type ExtensionHandler = (event: E, ctx: ExtensionContext) => Promise | R | void; + +/** + * ExtensionAPI passed to extension factory functions. + */ +export interface ExtensionAPI { + // ========================================================================= + // Module Access + // ========================================================================= + + /** File logger for error/warning/debug messages */ + logger: typeof import("../logger").logger; + + /** Injected @sinclair/typebox module for defining tool parameters */ + typebox: typeof import("@sinclair/typebox"); + + /** Injected pi-coding-agent exports for accessing SDK utilities */ + pi: typeof import("../../index.js"); + + // ========================================================================= + // Event Subscription + // ========================================================================= + + on(event: "session_start", handler: ExtensionHandler): void; + on( + event: "session_before_switch", + handler: ExtensionHandler, + ): void; + on(event: "session_switch", handler: ExtensionHandler): void; + on( + event: "session_before_branch", + handler: ExtensionHandler, + ): void; + on(event: "session_branch", handler: ExtensionHandler): void; + on( + event: "session_before_compact", + handler: ExtensionHandler, + ): void; + on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_shutdown", handler: ExtensionHandler): void; + on(event: "session_before_tree", handler: ExtensionHandler): void; + on(event: "session_tree", handler: ExtensionHandler): void; + on(event: "context", handler: ExtensionHandler): void; + on(event: "before_agent_start", handler: ExtensionHandler): void; + on(event: "agent_start", handler: ExtensionHandler): void; + on(event: "agent_end", handler: ExtensionHandler): void; + on(event: "turn_start", handler: ExtensionHandler): void; + on(event: "turn_end", handler: ExtensionHandler): void; + on(event: "tool_call", handler: ExtensionHandler): void; + on(event: "tool_result", handler: ExtensionHandler): void; + + // ========================================================================= + // Tool Registration + // ========================================================================= + + /** Register a tool that the LLM can call. */ + registerTool(tool: ToolDefinition): void; + + // ========================================================================= + // Command, Shortcut, Flag Registration + // ========================================================================= + + /** Register a custom command. */ + registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; + + /** Register a keyboard shortcut. */ + registerShortcut( + shortcut: KeyId, + options: { + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + }, + ): void; + + /** Register a CLI flag. */ + registerFlag( + name: string, + options: { + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + }, + ): void; + + /** Get the value of a registered CLI flag. */ + getFlag(name: string): boolean | string | undefined; + + // ========================================================================= + // Message Rendering + // ========================================================================= + + /** Register a custom renderer for CustomMessageEntry. */ + registerMessageRenderer(customType: string, renderer: MessageRenderer): void; + + // ========================================================================= + // Actions + // ========================================================================= + + /** Send a custom message to the session. */ + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): void; + + /** Append a custom entry to the session for state persistence (not sent to LLM). */ + appendEntry(customType: string, data?: T): void; + + /** Execute a shell command. */ + exec(command: string, args: string[], options?: ExecOptions): Promise; + + /** Get the list of currently active tool names. */ + getActiveTools(): string[]; + + /** Get all configured tools (built-in + extension tools). */ + getAllTools(): string[]; + + /** Set the active tools by name. */ + setActiveTools(toolNames: string[]): void; + + /** Shared event bus for extension communication. */ + events: EventBus; +} + +/** Extension factory function type. */ +export type ExtensionFactory = (pi: ExtensionAPI) => void; + +// ============================================================================ +// Loaded Extension Types +// ============================================================================ + +export interface RegisteredTool { + definition: ToolDefinition; + extensionPath: string; +} + +export interface ExtensionFlag { + name: string; + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + extensionPath: string; +} + +export interface ExtensionShortcut { + shortcut: KeyId; + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + extensionPath: string; +} + +type HandlerFn = (...args: unknown[]) => Promise; + +export type SendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, +) => void; + +export type AppendEntryHandler = (customType: string, data?: T) => void; + +export type GetActiveToolsHandler = () => string[]; + +export type GetAllToolsHandler = () => string[]; + +export type SetActiveToolsHandler = (toolNames: string[]) => void; + +/** Loaded extension with all registered items. */ +export interface LoadedExtension { + path: string; + resolvedPath: string; + handlers: Map; + tools: Map; + messageRenderers: Map; + commands: Map; + flags: Map; + flagValues: Map; + shortcuts: Map; + setSendMessageHandler: (handler: SendMessageHandler) => void; + setAppendEntryHandler: (handler: AppendEntryHandler) => void; + setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; + setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; + setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; + setFlagValue: (name: string, value: boolean | string) => void; +} + +/** Result of loading extensions. */ +export interface LoadExtensionsResult { + extensions: LoadedExtension[]; + errors: Array<{ path: string; error: string }>; + setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void; +} + +// ============================================================================ +// Extension Error +// ============================================================================ + +export interface ExtensionError { + extensionPath: string; + event: string; + error: string; + stack?: string; +} diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts new file mode 100644 index 000000000..d6f3eb5c5 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -0,0 +1,147 @@ +/** + * Tool wrappers for extensions. + */ + +import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; +import type { Theme } from "../../modes/interactive/theme/theme"; +import type { ExtensionRunner } from "./runner"; +import type { ExtensionContext, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types"; + +/** + * Wrap a RegisteredTool into an AgentTool. + */ +export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: () => ExtensionContext): AgentTool { + const { definition } = registeredTool; + return { + name: definition.name, + label: definition.label, + description: definition.description, + parameters: definition.parameters, + execute: (toolCallId, params, signal, onUpdate) => + definition.execute(toolCallId, params, onUpdate, getContext(), signal), + renderCall: definition.renderCall ? (args, theme) => definition.renderCall?.(args, theme as Theme) : undefined, + renderResult: definition.renderResult + ? (result, options, theme) => + definition.renderResult?.( + result, + { expanded: options.expanded, isPartial: options.isPartial, spinnerFrame: options.spinnerFrame }, + theme as Theme, + ) + : undefined, + }; +} + +/** + * Wrap all registered tools into AgentTools. + */ +export function wrapRegisteredTools( + registeredTools: RegisteredTool[], + getContext: () => ExtensionContext, +): AgentTool[] { + return registeredTools.map((rt) => wrapRegisteredTool(rt, getContext)); +} + +/** + * Wrap a tool with extension callbacks for interception. + * - Emits tool_call event before execution (can block) + * - Emits tool_result event after execution (can modify result) + */ +export function wrapToolWithExtensions(tool: AgentTool, runner: ExtensionRunner): AgentTool { + return { + ...tool, + execute: async ( + toolCallId: string, + params: Record, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ) => { + // Emit tool_call event - extensions can block execution + if (runner.hasHandlers("tool_call")) { + try { + const callResult = (await runner.emitToolCall({ + type: "tool_call", + toolName: tool.name, + toolCallId, + input: params, + })) as ToolCallEventResult | undefined; + + if (callResult?.block) { + const reason = callResult.reason || "Tool execution was blocked by an extension"; + throw new Error(reason); + } + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error(`Extension failed, blocking execution: ${String(err)}`); + } + } + + // Execute the actual tool + let result: { content: any; details: T }; + let executionError: Error | undefined; + + try { + result = await tool.execute(toolCallId, params, signal, onUpdate, context); + } catch (err) { + executionError = err instanceof Error ? err : new Error(String(err)); + result = { + content: [{ type: "text", text: executionError.message }], + details: undefined as T, + }; + } + + // Emit tool_result event - extensions can modify the result and error status + if (runner.hasHandlers("tool_result")) { + const resultResult = (await runner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: result.content, + details: result.details, + isError: !!executionError, + })) as ToolResultEventResult | undefined; + + if (resultResult) { + const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content; + const modifiedDetails = (resultResult.details ?? result.details) as T; + + // Extension can override error status + if (resultResult.isError === true && !executionError) { + // Extension marks a successful result as error + const textBlocks = (modifiedContent ?? []).filter((c): c is TextContent => c.type === "text"); + const errorText = + textBlocks.map((t) => t.text).join("\n") || "Tool result marked as error by extension"; + throw new Error(errorText); + } + if (resultResult.isError === false && executionError) { + // Extension clears the error - return success + return { content: modifiedContent, details: modifiedDetails }; + } + + // Error status unchanged, but content/details may be modified + if (executionError) { + throw executionError; + } + return { content: modifiedContent, details: modifiedDetails }; + } + } + + // No extension modification + if (executionError) { + throw executionError; + } + return result; + }, + }; +} + +/** + * Wrap all tools with extension callbacks. + */ +export function wrapToolsWithExtensions(tools: AgentTool[], runner: ExtensionRunner): AgentTool[] { + return tools.map((tool) => wrapToolWithExtensions(tool, runner)); +} diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index b5992ee54..16a69ebe8 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -268,7 +268,7 @@ export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; /** Whether the compaction entry was provided by a hook */ - fromHook: boolean; + fromExtension: boolean; } /** Fired on process exit (SIGINT/SIGTERM) */ @@ -309,7 +309,7 @@ export interface SessionTreeEvent { /** Branch summary entry if one was created */ summaryEntry?: BranchSummaryEntry; /** Whether summary came from hook */ - fromHook?: boolean; + fromExtension?: boolean; } /** Union of all session event types */ diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index cfd879633..8d310bde3 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -14,27 +14,16 @@ export { export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor"; export type { CompactionResult } from "./compaction/index"; export { - type CustomTool, - type CustomToolAPI, - type CustomToolFactory, - type CustomToolsLoadResult, - type CustomToolUIContext, - discoverAndLoadCustomTools, - type ExecResult, - type LoadedCustomTool, - loadCustomTools, - type RenderResultOptions, -} from "./custom-tools/index"; -export { - type HookAPI, - type HookContext, - type HookError, - type HookEvent, - type HookFactory, - HookRunner, - type HookUIContext, - loadHooks, -} from "./hooks/index"; + discoverAndLoadExtensions, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, + type ExtensionFactory, + ExtensionRunner, + type ExtensionUIContext, + loadExtensionFromFactory, + type ToolDefinition, +} from "./extensions/index"; export { createMCPManager, discoverAndLoadMCPTools, diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts new file mode 100644 index 000000000..4233a0c9f --- /dev/null +++ b/packages/coding-agent/src/core/keybindings.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + DEFAULT_EDITOR_KEYBINDINGS, + type EditorAction, + type EditorKeybindingsConfig, + EditorKeybindingsManager, + type KeyId, + matchesKey, + setEditorKeybindings, +} from "@oh-my-pi/pi-tui"; +import { getAgentDir } from "../config"; + +/** + * Application-level actions (coding agent specific). + */ +export type AppAction = + | "interrupt" + | "clear" + | "exit" + | "suspend" + | "cycleThinkingLevel" + | "cycleModelForward" + | "cycleModelBackward" + | "selectModel" + | "expandTools" + | "toggleThinking" + | "externalEditor" + | "followUp"; + +/** + * All configurable actions. + */ +export type KeyAction = AppAction | EditorAction; + +/** + * Full keybindings configuration (app + editor actions). + */ +export type KeybindingsConfig = { + [K in KeyAction]?: KeyId | KeyId[]; +}; + +/** + * Default application keybindings. + */ +export const DEFAULT_APP_KEYBINDINGS: Record = { + interrupt: "escape", + clear: "ctrl+c", + exit: "ctrl+d", + suspend: "ctrl+z", + cycleThinkingLevel: "shift+tab", + cycleModelForward: "ctrl+p", + cycleModelBackward: "shift+ctrl+p", + selectModel: "ctrl+l", + expandTools: "ctrl+o", + toggleThinking: "ctrl+t", + externalEditor: "ctrl+g", + followUp: "alt+enter", +}; + +/** + * All default keybindings (app + editor). + */ +export const DEFAULT_KEYBINDINGS: Required = { + ...DEFAULT_EDITOR_KEYBINDINGS, + ...DEFAULT_APP_KEYBINDINGS, +}; + +// App actions list for type checking +const APP_ACTIONS: AppAction[] = [ + "interrupt", + "clear", + "exit", + "suspend", + "cycleThinkingLevel", + "cycleModelForward", + "cycleModelBackward", + "selectModel", + "expandTools", + "toggleThinking", + "externalEditor", + "followUp", +]; + +function isAppAction(action: string): action is AppAction { + return APP_ACTIONS.includes(action as AppAction); +} + +/** + * Manages all keybindings (app + editor). + */ +export class KeybindingsManager { + private config: KeybindingsConfig; + private appActionToKeys: Map; + + private constructor(config: KeybindingsConfig) { + this.config = config; + this.appActionToKeys = new Map(); + this.buildMaps(); + } + + /** + * Create from config file and set up editor keybindings. + */ + static async create(agentDir: string = getAgentDir()): Promise { + const configPath = join(agentDir, "keybindings.json"); + const config = await KeybindingsManager.loadFromFile(configPath); + const manager = new KeybindingsManager(config); + + // Set up editor keybindings globally + const editorConfig: EditorKeybindingsConfig = {}; + for (const [action, keys] of Object.entries(config)) { + if (!isAppAction(action)) { + editorConfig[action as EditorAction] = keys; + } + } + setEditorKeybindings(new EditorKeybindingsManager(editorConfig)); + + return manager; + } + + /** + * Create in-memory. + */ + static inMemory(config: KeybindingsConfig = {}): KeybindingsManager { + return new KeybindingsManager(config); + } + + private static async loadFromFile(path: string): Promise { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return {}; + } + } + + private buildMaps(): void { + this.appActionToKeys.clear(); + + // Set defaults for app actions + for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) { + const keyArray = Array.isArray(keys) ? keys : [keys]; + this.appActionToKeys.set(action as AppAction, [...keyArray]); + } + + // Override with user config (app actions only) + for (const [action, keys] of Object.entries(this.config)) { + if (keys === undefined || !isAppAction(action)) continue; + const keyArray = Array.isArray(keys) ? keys : [keys]; + this.appActionToKeys.set(action, keyArray); + } + } + + /** + * Check if input matches an app action. + */ + matches(data: string, action: AppAction): boolean { + const keys = this.appActionToKeys.get(action); + if (!keys) return false; + for (const key of keys) { + if (matchesKey(data, key)) return true; + } + return false; + } + + /** + * Get keys bound to an app action. + */ + getKeys(action: AppAction): KeyId[] { + return this.appActionToKeys.get(action) ?? []; + } + + /** + * Get display string for an action. + */ + getDisplayString(action: AppAction): string { + const keys = this.getKeys(action); + if (keys.length === 0) return ""; + if (keys.length === 1) return keys[0]!; + return keys.join("/"); + } + + /** + * Get the full effective config. + */ + getEffectiveConfig(): Required { + const result = { ...DEFAULT_KEYBINDINGS }; + for (const [action, keys] of Object.entries(this.config)) { + if (keys !== undefined) { + (result as KeybindingsConfig)[action as KeyAction] = keys; + } + } + return result; + } +} + +// Re-export for convenience +export type { EditorAction, KeyId }; diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 2b80a5fe2..6bd84fe1e 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -35,11 +35,24 @@ export interface BashExecutionMessage { truncated: boolean; fullOutputPath?: string; timestamp: number; + /** If true, this message is excluded from LLM context (!! prefix) */ + excludeFromContext?: boolean; } /** - * Message type for hook-injected messages via sendMessage(). - * These are custom messages that hooks can inject into the conversation. + * Message type for extension-injected messages via sendMessage(). + */ +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +/** + * Legacy hook message type (pre-extensions). Kept for session migration. */ export interface HookMessage { role: "hookMessage"; @@ -78,9 +91,11 @@ export interface FileMentionMessage { } // Extend CustomAgentMessages via declaration merging +// Legacy hookMessage is kept for migration; new code should use custom. declare module "@oh-my-pi/pi-agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; + custom: CustomMessage; hookMessage: HookMessage; branchSummary: BranchSummaryMessage; compactionSummary: CompactionSummaryMessage; @@ -125,22 +140,22 @@ export function createCompactionSummaryMessage( ): CompactionSummaryMessage { return { role: "compactionSummary", - summary: summary, + summary, tokensBefore, timestamp: new Date(timestamp).getTime(), }; } /** Convert CustomMessageEntry to AgentMessage format */ -export function createHookMessage( +export function createCustomMessage( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string, -): HookMessage { +): CustomMessage { return { - role: "hookMessage", + role: "custom", customType, content, display, @@ -155,18 +170,22 @@ export function createHookMessage( * This is used by: * - Agent's transormToLlm option (for prompt calls and queued messages) * - Compaction's generateSummary (for summarization) - * - Custom hooks and tools + * - Custom extensions and tools */ export function convertToLlm(messages: AgentMessage[]): Message[] { return messages .map((m): Message | undefined => { switch (m.role) { case "bashExecution": + if (m.excludeFromContext) { + return undefined; + } return { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], timestamp: m.timestamp, }; + case "custom": case "hookMessage": { const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; return { diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index e6863f664..6b522972b 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -35,8 +35,10 @@ const ModelDefinitionSchema = Type.Object({ Type.Union([ Type.Literal("openai-completions"), Type.Literal("openai-responses"), + Type.Literal("openai-codex-responses"), Type.Literal("anthropic-messages"), Type.Literal("google-generative-ai"), + Type.Literal("google-vertex"), ]), ), reasoning: Type.Boolean(), @@ -54,19 +56,21 @@ const ModelDefinitionSchema = Type.Object({ }); const ProviderConfigSchema = Type.Object({ - baseUrl: Type.String({ minLength: 1 }), - apiKey: Type.String({ minLength: 1 }), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + apiKey: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional( Type.Union([ Type.Literal("openai-completions"), Type.Literal("openai-responses"), + Type.Literal("openai-codex-responses"), Type.Literal("anthropic-messages"), Type.Literal("google-generative-ai"), + Type.Literal("google-vertex"), ]), ), headers: Type.Optional(Type.Record(Type.String(), Type.String())), authHeader: Type.Optional(Type.Boolean()), - models: Type.Array(ModelDefinitionSchema), + models: Type.Optional(Type.Array(ModelDefinitionSchema)), }); const ModelsConfigSchema = Type.Object({ @@ -75,6 +79,27 @@ const ModelsConfigSchema = Type.Object({ type ModelsConfig = Static; +/** Provider override config (baseUrl, headers, apiKey) without custom models */ +interface ProviderOverride { + baseUrl?: string; + headers?: Record; + apiKey?: string; +} + +/** Result of loading custom models from models.json */ +interface CustomModelsResult { + models: Model[]; + /** Providers with custom models (full replacement) */ + replacedProviders: Set; + /** Providers with only baseUrl/headers override (no custom models) */ + overrides: Map; + error: string | undefined; +} + +function emptyCustomModelsResult(error?: string): CustomModelsResult { + return { models: [], replacedProviders: new Set(), overrides: new Map(), error }; +} + /** * Resolve an API key config value to an actual key. * Checks environment variable first, then treats as literal. @@ -111,8 +136,7 @@ export class ModelRegistry { } return undefined; }); - - // Load models + // Load models synchronously in constructor this.loadModels(); } @@ -133,15 +157,10 @@ export class ModelRegistry { } private loadModels(): void { - // Load built-in models - const builtInModels: Model[] = []; - for (const provider of getProviders()) { - const providerModels = getModels(provider as KnownProvider); - builtInModels.push(...(providerModels as Model[])); - } - - // Load custom models from models.json (check primary path, then fallbacks) + // Load custom models from models.json first (to know which providers to skip/override) let customModels: Model[] = []; + let replacedProviders: Set = new Set(); + let overrides: Map = new Map(); const pathsToCheck = this.modelsJsonPath ? [this.modelsJsonPath, ...this.fallbackPaths] : this.fallbackPaths; if (pathsToCheck.length > 0) { @@ -157,11 +176,14 @@ export class ModelRegistry { // Keep built-in models even if custom models failed to load } else { customModels = result.models; + replacedProviders = result.replacedProviders; + overrides = result.overrides; } break; // Use first existing file } } + const builtInModels = this.loadBuiltInModels(replacedProviders, overrides); const combined = [...builtInModels, ...customModels]; // Update github-copilot base URL based on OAuth credentials @@ -177,9 +199,27 @@ export class ModelRegistry { } } - private loadCustomModels(modelsJsonPath: string): { models: Model[]; error: string | undefined } { + /** Load built-in models, skipping replaced providers and applying overrides */ + private loadBuiltInModels(replacedProviders: Set, overrides: Map): Model[] { + return getProviders() + .filter((provider) => !replacedProviders.has(provider)) + .flatMap((provider) => { + const models = getModels(provider as KnownProvider) as Model[]; + const override = overrides.get(provider); + if (!override) return models; + + // Apply baseUrl/headers override to all models of this provider + return models.map((m) => ({ + ...m, + baseUrl: override.baseUrl ?? m.baseUrl, + headers: override.headers ? { ...m.headers, ...override.headers } : m.headers, + })); + }); + } + + private loadCustomModels(modelsJsonPath: string): CustomModelsResult { if (!existsSync(modelsJsonPath)) { - return { models: [], error: undefined }; + return emptyCustomModelsResult(); } try { @@ -193,38 +233,68 @@ export class ModelRegistry { const errors = validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") || "Unknown schema error"; - return { - models: [], - error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`, - }; + return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`); } // Additional validation this.validateConfig(config); - // Parse models - return { models: this.parseModels(config), error: undefined }; + // Separate providers into "full replacement" (has models) vs "override-only" (no models) + const replacedProviders = new Set(); + const overrides = new Map(); + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + if (providerConfig.models && providerConfig.models.length > 0) { + // Has custom models -> full replacement + replacedProviders.add(providerName); + } else { + // No models -> just override baseUrl/headers on built-in + overrides.set(providerName, { + baseUrl: providerConfig.baseUrl, + headers: providerConfig.headers, + apiKey: providerConfig.apiKey, + }); + // Store API key for fallback resolver + if (providerConfig.apiKey) { + this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + } + } + } + + return { models: this.parseModels(config), replacedProviders, overrides, error: undefined }; } catch (error) { if (error instanceof SyntaxError) { - return { - models: [], - error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`, - }; + return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`); } - return { - models: [], - error: `Failed to load models.json: ${ - error instanceof Error ? error.message : error - }\n\nFile: ${modelsJsonPath}`, - }; + return emptyCustomModelsResult( + `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`, + ); } } private validateConfig(config: ModelsConfig): void { for (const [providerName, providerConfig] of Object.entries(config.providers)) { const hasProviderApi = !!providerConfig.api; + const models = providerConfig.models ?? []; - for (const modelDef of providerConfig.models) { + if (models.length === 0) { + // Override-only config: just needs baseUrl (to override built-in) + if (!providerConfig.baseUrl) { + throw new Error( + `Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`, + ); + } + } else { + // Full replacement: needs baseUrl and apiKey + if (!providerConfig.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); + } + if (!providerConfig.apiKey) { + throw new Error(`Provider ${providerName}: "apiKey" is required when defining custom models.`); + } + } + + for (const modelDef of models) { const hasModelApi = !!modelDef.api; if (!hasProviderApi && !hasModelApi) { @@ -247,10 +317,15 @@ export class ModelRegistry { const models: Model[] = []; for (const [providerName, providerConfig] of Object.entries(config.providers)) { - // Store API key config for fallback resolver - this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + const modelDefs = providerConfig.models ?? []; + if (modelDefs.length === 0) continue; // Override-only, no custom models - for (const modelDef of providerConfig.models) { + // Store API key config for fallback resolver + if (providerConfig.apiKey) { + this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + } + + for (const modelDef of modelDefs) { const api = modelDef.api || providerConfig.api; if (!api) continue; @@ -261,19 +336,20 @@ export class ModelRegistry { : undefined; // If authHeader is true, add Authorization header with resolved API key - if (providerConfig.authHeader) { + if (providerConfig.authHeader && providerConfig.apiKey) { const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey); if (resolvedKey) { headers = { ...headers, Authorization: `Bearer ${resolvedKey}` }; } } + // baseUrl is validated to exist for providers with models models.push({ id: modelDef.id, name: modelDef.name, api: api as Api, provider: providerName, - baseUrl: providerConfig.baseUrl, + baseUrl: providerConfig.baseUrl!, reasoning: modelDef.reasoning, input: modelDef.input as ("text" | "image")[], cost: modelDef.cost, @@ -297,17 +373,11 @@ export class ModelRegistry { } /** - * Get only models that have valid API keys available. + * Get only models that have auth configured. + * This is a fast check that doesn't refresh OAuth tokens. */ - async getAvailable(): Promise[]> { - const available: Model[] = []; - for (const model of this.models) { - const apiKey = await this.authStorage.getApiKey(model.provider); - if (apiKey) { - available.push(model); - } - } - return available; + getAvailable(): Model[] { + return this.models.filter((m) => this.authStorage.hasAuth(m.provider)); } /** diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 51ae8acbe..214c7abd4 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,9 +13,11 @@ import type { ModelRegistry } from "./model-registry"; export const defaultModelPerProvider: Record = { anthropic: "claude-sonnet-4-5", openai: "gpt-5.1-codex", + "openai-codex": "codex-max", google: "gemini-2.5-pro", "google-gemini-cli": "gemini-2.5-pro", "google-antigravity": "gemini-3-pro-high", + "google-vertex": "gemini-2.5-pro", "github-copilot": "gpt-4o", openrouter: "openai/gpt-5.1-codex", xai: "grok-4-fast-non-reasoning", @@ -192,7 +194,7 @@ export function parseModelPattern(pattern: string, availableModels: Model[] * strips colon-suffixes to find a match. */ export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise { - const availableModels = await modelRegistry.getAvailable(); + const availableModels = modelRegistry.getAvailable(); const scopedModels: ScopedModel[] = []; for (const pattern of patterns) { @@ -321,7 +323,7 @@ export async function findInitialModel(options: { } // 4. Try first available model with valid API key - const availableModels = await modelRegistry.getAvailable(); + const availableModels = modelRegistry.getAvailable(); if (availableModels.length > 0) { // Try to find a default model from known providers @@ -382,7 +384,7 @@ export async function restoreModelFromSession( } // Try to find any available model - const availableModels = await modelRegistry.getAvailable(); + const availableModels = modelRegistry.getAvailable(); if (availableModels.length > 0) { // Try to find a default model from known providers @@ -427,7 +429,7 @@ export async function findSmolModel( modelRegistry: ModelRegistry, savedModel?: string, ): Promise | undefined> { - const availableModels = await modelRegistry.getAvailable(); + const availableModels = modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; // 1. Try saved model from settings @@ -466,7 +468,7 @@ export async function findSlowModel( modelRegistry: ModelRegistry, savedModel?: string, ): Promise | undefined> { - const availableModels = await modelRegistry.getAvailable(); + const availableModels = modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; // 1. Try saved model from settings diff --git a/packages/coding-agent/src/core/prompt-templates.ts b/packages/coding-agent/src/core/prompt-templates.ts new file mode 100644 index 000000000..9ca6b670b --- /dev/null +++ b/packages/coding-agent/src/core/prompt-templates.ts @@ -0,0 +1,242 @@ +import { join, resolve } from "node:path"; +import { CONFIG_DIR_NAME, getPromptsDir } from "../config"; + +/** + * Represents a prompt template loaded from a markdown file + */ +export interface PromptTemplate { + name: string; + description: string; + content: string; + source: string; // e.g., "(user)", "(project)", "(project:frontend)" +} + +/** + * Parse YAML frontmatter from markdown content + * Returns { frontmatter, content } where content has frontmatter stripped + */ +function parseFrontmatter(content: string): { frontmatter: Record; content: string } { + const frontmatter: Record = {}; + + if (!content.startsWith("---")) { + return { frontmatter, content }; + } + + const endIndex = content.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, content }; + } + + const frontmatterBlock = content.slice(4, endIndex); + const remainingContent = content.slice(endIndex + 4).trim(); + + // Simple YAML parsing - just key: value pairs + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^(\w+):\s*(.*)$/); + if (match) { + frontmatter[match[1]] = match[2].trim(); + } + } + + return { frontmatter, content: remainingContent }; +} + +/** + * Parse command arguments respecting quoted strings (bash-style) + * Returns array of arguments + */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Substitute argument placeholders in template content + * Supports $1, $2, ... for positional args, $@ and $ARGUMENTS for all args + * + * Note: Replacement happens on the template string only. Argument values + * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. + */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + + // Replace $1, $2, etc. with positional args FIRST (before wildcards) + // This prevents wildcard replacement values containing $ patterns from being re-substituted + result = result.replace(/\$(\d+)/g, (_, num) => { + const index = parseInt(num, 10) - 1; + return args[index] ?? ""; + }); + + // Pre-compute all args joined (optimization) + const allArgs = args.join(" "); + + // Replace $ARGUMENTS with all args joined (new syntax, aligns with Claude, Codex, OpenCode) + result = result.replace(/\$ARGUMENTS/g, allArgs); + + // Replace $@ with all args joined (existing syntax) + result = result.replace(/\$@/g, allArgs); + + return result; +} + +/** + * Recursively scan a directory for .md files (and symlinks to .md files) and load them as prompt templates + */ +async function loadTemplatesFromDir( + dir: string, + source: "user" | "project", + subdir: string = "", +): Promise { + const templates: PromptTemplate[] = []; + + try { + const stat = await Bun.file(`${dir}/.`).exists(); + if (!stat) return templates; + } catch { + return templates; + } + + try { + const glob = new Bun.Glob("**/*"); + const entries = []; + for await (const entry of glob.scan({ cwd: dir, absolute: false, onlyFiles: false })) { + entries.push(entry); + } + + // Group by path depth to process directories before deeply nested files + entries.sort((a, b) => a.split("/").length - b.split("/").length); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const file = Bun.file(fullPath); + + try { + const stat = await file.exists(); + if (!stat) continue; + + if (entry.endsWith(".md")) { + const rawContent = await file.text(); + const { frontmatter, content } = parseFrontmatter(rawContent); + + const name = entry.split("/").pop()!.slice(0, -3); // Remove .md extension + + // Build source string based on subdirectory structure + const entryDir = entry.includes("/") ? entry.split("/").slice(0, -1).join(":") : ""; + const fullSubdir = subdir && entryDir ? `${subdir}:${entryDir}` : entryDir || subdir; + + let sourceStr: string; + if (source === "user") { + sourceStr = fullSubdir ? `(user:${fullSubdir})` : "(user)"; + } else { + sourceStr = fullSubdir ? `(project:${fullSubdir})` : "(project)"; + } + + // Get description from frontmatter or first non-empty line + let description = frontmatter.description || ""; + if (!description) { + const firstLine = content.split("\n").find((line) => line.trim()); + if (firstLine) { + // Truncate if too long + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + } + + // Append source to description + description = description ? `${description} ${sourceStr}` : sourceStr; + + templates.push({ + name, + description, + content, + source: sourceStr, + }); + } + } catch (_error) { + // Silently skip files that can't be read + } + } + } catch (_error) { + // Silently skip directories that can't be read + } + + return templates; +} + +export interface LoadPromptTemplatesOptions { + /** Working directory for project-local templates. Default: process.cwd() */ + cwd?: string; + /** Agent config directory for global templates. Default: from getPromptsDir() */ + agentDir?: string; +} + +/** + * Load all prompt templates from: + * 1. Global: agentDir/prompts/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ + */ +export async function loadPromptTemplates(options: LoadPromptTemplatesOptions = {}): Promise { + const resolvedCwd = options.cwd ?? process.cwd(); + const resolvedAgentDir = options.agentDir ?? getPromptsDir(); + + const templates: PromptTemplate[] = []; + + // 1. Load global templates from agentDir/prompts/ + // Note: if agentDir is provided, it should be the agent dir, not the prompts dir + const globalPromptsDir = options.agentDir ? join(options.agentDir, "prompts") : resolvedAgentDir; + templates.push(...(await loadTemplatesFromDir(globalPromptsDir, "user"))); + + // 2. Load project templates from cwd/{CONFIG_DIR_NAME}/prompts/ + const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + templates.push(...(await loadTemplatesFromDir(projectPromptsDir, "project"))); + + return templates; +} + +/** + * Expand a prompt template if it matches a template name. + * Returns the expanded content or the original text if not a template. + */ +export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { + if (!text.startsWith("/")) return text; + + const spaceIndex = text.indexOf(" "); + const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + + const template = templates.find((t) => t.name === templateName); + if (template) { + const args = parseCommandArgs(argsString); + return substituteArgs(template.content, args); + } + + return text; +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index baf50c9c8..4ddb3fde3 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -9,12 +9,9 @@ * // Minimal - everything auto-discovered * const session = await createAgentSession(); * - * // With custom hooks + * // With custom extensions * const session = await createAgentSession({ - * hooks: [ - * ...await discoverHooks(), - * { factory: myHookFactory }, - * ], + * extensions: [myExtensionFactory], * }); * * // Full control @@ -22,7 +19,7 @@ * model: myModel, * getApiKey: async () => process.env.MY_KEY, * tools: [readTool, bashTool], - * hooks: [], + * extensions: [], * skills: [], * sessionFile: false, * }); @@ -30,37 +27,45 @@ */ import { join } from "node:path"; -import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; +import type { Component } from "@oh-my-pi/pi-tui"; import chalk from "chalk"; // Import discovery to register all providers on startup import "../discovery"; import { loadSync as loadCapability } from "../capability/index"; import { type Rule, ruleCapability } from "../capability/rule"; import { getAgentDir, getConfigDirPaths } from "../config"; +import { initializeWithSettings } from "../discovery"; import { AgentSession } from "./agent-session"; import { AuthStorage } from "./auth-storage"; import { type CustomCommandsLoadResult, loadCustomCommands as loadCustomCommandsInternal, } from "./custom-commands/index"; +import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./custom-tools/types"; +import { createEventBus, type EventBus } from "./event-bus"; import { - type CustomToolsLoadResult, - discoverAndLoadCustomTools, - type LoadedCustomTool, - wrapCustomTools, -} from "./custom-tools/index"; -import type { CustomTool } from "./custom-tools/types"; -import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index"; -import type { HookFactory } from "./hooks/types"; + discoverAndLoadExtensions, + type ExtensionContext, + type ExtensionFactory, + ExtensionRunner, + type LoadExtensionsResult, + type LoadedExtension, + loadExtensionFromFactory, + type ToolDefinition, + wrapRegisteredTools, + wrapToolsWithExtensions, +} from "./extensions/index"; import { logger } from "./logger"; import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index"; import { convertToLlm } from "./messages"; import { ModelRegistry } from "./model-registry"; +import { parseModelString } from "./model-resolver"; +import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates"; import { SessionManager } from "./session-manager"; -import { type CommandsSettings, type Settings, SettingsManager, type SkillsSettings } from "./settings-manager"; +import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager"; import { loadSkills as loadSkillsInternal, type Skill } from "./skills"; -import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands"; import { buildSystemPrompt as buildSystemPromptInternal, loadProjectContextFiles as loadContextFilesInternal, @@ -71,8 +76,10 @@ import { getGeminiImageTools } from "./tools/gemini-image"; import { allTools, applyBashInterception, + baseCodingToolNames, bashTool, codingTools, + createAllTools, createBashTool, createCodingTools, createEditTool, @@ -87,12 +94,14 @@ import { editTool, filterRulebookRules, findTool, + getWebSearchTools, gitTool, grepTool, lsTool, readOnlyTools, readTool, type Tool, + type ToolName, warmupLspServers, writeTool, } from "./tools/index"; @@ -121,24 +130,29 @@ export interface CreateAgentSessionOptions { /** System prompt. String replaces default, function receives default and returns final. */ systemPrompt?: string | ((defaultPrompt: string) => string); - /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ + /** Built-in tools to use. Default: all coding tools (read, bash, edit, write, grep, find, ls, lsp, notebook, output, task, web_fetch, web_search) */ tools?: Tool[]; - /** Custom tools (replaces discovery). */ - customTools?: Array<{ path?: string; tool: CustomTool }>; - /** Additional custom tool paths to load (merged with discovery). */ - additionalCustomToolPaths?: string[]; + /** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */ + customTools?: (CustomTool | ToolDefinition)[]; + /** Inline extensions (merged with discovery). */ + extensions?: ExtensionFactory[]; + /** Additional extension paths to load (merged with discovery). */ + additionalExtensionPaths?: string[]; + /** + * Pre-loaded extensions (skips file discovery). + * @internal Used by CLI when extensions are loaded early to parse custom flags. + */ + preloadedExtensions?: LoadedExtension[]; - /** Hooks (replaces discovery). */ - hooks?: Array<{ path?: string; factory: HookFactory }>; - /** Additional hook paths to load (merged with discovery). */ - additionalHookPaths?: string[]; + /** Shared event bus for tool/extension communication. Default: creates new bus. */ + eventBus?: EventBus; /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ contextFiles?: Array<{ path: string; content: string }>; - /** Slash commands. Default: discovered from cwd/.omp/commands/ + agentDir/commands/ */ - slashCommands?: FileSlashCommand[]; + /** Prompt templates. Default: discovered from cwd/.omp/prompts/ + agentDir/prompts/ */ + promptTemplates?: PromptTemplate[]; /** Enable MCP server discovery from .mcp.json files. Default: true */ enableMCP?: boolean; @@ -160,8 +174,8 @@ export interface CreateAgentSessionOptions { export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; - /** Custom tools result (for UI context setup in interactive mode) */ - customToolsResult: CustomToolsLoadResult; + /** Extensions result (for UI context setup in interactive mode) */ + extensionsResult: LoadExtensionsResult; /** MCP manager for server lifecycle management (undefined if MCP disabled) */ mcpManager?: MCPManager; /** Warning if session was restored with a different model than saved */ @@ -173,12 +187,18 @@ export interface CreateAgentSessionResult { // Re-exports export type { CustomCommand, CustomCommandFactory } from "./custom-commands/types"; -export type { CustomTool } from "./custom-tools/types"; -export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types"; +export type { CustomTool, CustomToolFactory } from "./custom-tools/types"; +export type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + ExtensionFactory, + ToolDefinition, +} from "./extensions/index"; export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp/index"; +export type { PromptTemplate } from "./prompt-templates"; export type { Settings, SkillsSettings } from "./settings-manager"; export type { Skill } from "./skills"; -export type { FileSlashCommand } from "./slash-commands"; export type { Tool } from "./tools/index"; export { @@ -219,7 +239,7 @@ function getDefaultAgentDir(): string { * Create an AuthStorage instance with fallback support. * Reads from primary path first, then falls back to legacy paths (.pi, .claude). */ -export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage { +export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise { const primaryPath = join(agentDir, "auth.json"); // Get all auth.json paths (user-level only), excluding the primary const allPaths = getConfigDirPaths("auth.json", { project: false }); @@ -227,14 +247,19 @@ export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Au logger.debug("discoverAuthStorage", { agentDir, primaryPath, allPaths, fallbackPaths }); - return new AuthStorage(primaryPath, fallbackPaths); + const storage = new AuthStorage(primaryPath, fallbackPaths); + await storage.reload(); + return storage; } /** * Create a ModelRegistry with fallback support. * Reads from primary path first, then falls back to legacy paths (.pi, .claude). */ -export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry { +export async function discoverModels( + authStorage: AuthStorage, + agentDir: string = getDefaultAgentDir(), +): Promise { const primaryPath = join(agentDir, "models.json"); // Get all models.json paths (user-level only), excluding the primary const allPaths = getConfigDirPaths("models.json", { project: false }); @@ -242,51 +267,18 @@ export function discoverModels(authStorage: AuthStorage, agentDir: string = getD logger.debug("discoverModels", { primaryPath, fallbackPaths }); - return new ModelRegistry(authStorage, primaryPath, fallbackPaths); + const registry = new ModelRegistry(authStorage, primaryPath, fallbackPaths); + await registry.refresh(); + return registry; } /** - * Discover hooks from cwd and agentDir. + * Discover extensions from cwd. */ -export async function discoverHooks( - cwd?: string, - _agentDir?: string, -): Promise> { +export async function discoverExtensions(cwd?: string): Promise { const resolvedCwd = cwd ?? process.cwd(); - const { hooks, errors } = await discoverAndLoadHooks([], resolvedCwd); - - // Log errors but don't fail - for (const { path, error } of errors) { - console.error(`Failed to load hook "${path}": ${error}`); - } - - return hooks.map((h) => ({ - path: h.path, - factory: createFactoryFromLoadedHook(h), - })); -} - -/** - * Discover custom tools from cwd and agentDir. - */ -export async function discoverCustomTools( - cwd?: string, - _agentDir?: string, -): Promise> { - const resolvedCwd = cwd ?? process.cwd(); - - const { tools, errors } = await discoverAndLoadCustomTools([], resolvedCwd, Object.keys(allTools)); - - // Log errors but don't fail - for (const { path, error } of errors) { - console.error(`Failed to load custom tool "${path}": ${error}`); - } - - return tools.map((t) => ({ - path: t.path, - tool: t.tool, - })); + return discoverAndLoadExtensions([], resolvedCwd); } /** @@ -314,15 +306,12 @@ export function discoverContextFiles( } /** - * Discover slash commands from cwd and agentDir. + * Discover prompt templates from cwd and agentDir. */ -export function discoverSlashCommands( - cwd?: string, - _agentDir?: string, - _settings?: CommandsSettings, -): FileSlashCommand[] { - return loadSlashCommandsInternal({ +export async function discoverPromptTemplates(cwd?: string, agentDir?: string): Promise { + return await loadPromptTemplatesInternal({ cwd: cwd ?? process.cwd(), + agentDir: agentDir ?? getDefaultAgentDir(), }); } @@ -382,15 +371,16 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { return { modelRoles: manager.getModelRoles(), defaultThinkingLevel: manager.getDefaultThinkingLevel(), - queueMode: manager.getQueueMode(), + steeringMode: manager.getSteeringMode(), + followUpMode: manager.getFollowUpMode(), + interruptMode: manager.getInterruptMode(), theme: manager.getTheme(), compaction: manager.getCompactionSettings(), retry: manager.getRetrySettings(), hideThinkingBlock: manager.getHideThinkingBlock(), shellPath: manager.getShellPath(), collapseChangelog: manager.getCollapseChangelog(), - hooks: manager.getHookPaths(), - customTools: manager.getCustomToolPaths(), + extensions: manager.getExtensionPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, }; @@ -398,84 +388,85 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { // Internal Helpers -/** - * Create a HookFactory from a LoadedHook. - * This allows mixing discovered hooks with inline hooks. - */ -function createFactoryFromLoadedHook(loaded: LoadedHook): HookFactory { - return (api) => { - for (const [eventType, handlers] of loaded.handlers) { - for (const handler of handlers) { - api.on(eventType as any, handler as any); - } - } +function createCustomToolContext(ctx: ExtensionContext): CustomToolContext { + return { + sessionManager: ctx.sessionManager, + modelRegistry: ctx.modelRegistry, + model: ctx.model, + isIdle: ctx.isIdle, + hasQueuedMessages: ctx.hasPendingMessages, + abort: ctx.abort, }; } -/** - * Convert hook definitions to LoadedHooks for the HookRunner. - */ -function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; factory: HookFactory }>): LoadedHook[] { - return definitions.map((def) => { - const handlers = new Map Promise>>(); - const messageRenderers = new Map(); - const commands = new Map(); - let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; - let appendEntryHandler: (customType: string, data?: any) => void = () => {}; - let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); - let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); - let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ - cancelled: false, - }); +function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool { + // To distinguish, we mark converted tools with a hidden symbol property. + // If the tool doesn't have this marker, it's a CustomTool that needs conversion. + return !(tool as any).__isToolDefinition; +} - const api = { - on: (event: string, handler: (...args: unknown[]) => Promise) => { - const list = handlers.get(event) ?? []; - list.push(handler); - handlers.set(event, list); - }, - sendMessage: (message: any, triggerTurn?: boolean) => { - sendMessageHandler(message, triggerTurn); - }, - appendEntry: (customType: string, data?: any) => { - appendEntryHandler(customType, data); - }, - registerMessageRenderer: (customType: string, renderer: any) => { - messageRenderers.set(customType, renderer); - }, - registerCommand: (name: string, options: any) => { - commands.set(name, { name, ...options }); - }, - newSession: (options?: any) => newSessionHandler(options), - branch: (entryId: string) => branchHandler(entryId), - navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), +const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition"); + +function customToolToDefinition(tool: CustomTool): ToolDefinition { + const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters, + hidden: tool.hidden, + execute: (toolCallId, params, onUpdate, ctx, signal) => + tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal), + onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined, + renderCall: tool.renderCall, + renderResult: tool.renderResult + ? (result, options, theme): Component => { + const component = tool.renderResult?.( + result, + { expanded: options.expanded, isPartial: options.isPartial, spinnerFrame: options.spinnerFrame }, + theme, + ); + // Return empty component if undefined to match Component type requirement + return component ?? ({ render: () => [] } as unknown as Component); + } + : undefined, + [TOOL_DEFINITION_MARKER]: true, + }; + return definition; +} + +function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory { + return (api) => { + for (const tool of tools) { + api.registerTool(customToolToDefinition(tool)); + } + + const runOnSession = async (event: CustomToolSessionEvent, ctx: ExtensionContext) => { + for (const tool of tools) { + if (!tool.onSession) continue; + try { + await tool.onSession(event, createCustomToolContext(ctx)); + } catch (err) { + logger.warn("Custom tool onSession error", { tool: tool.name, error: String(err) }); + } + } }; - def.factory(api as any); - - return { - path: def.path ?? "", - resolvedPath: def.path ?? "", - handlers, - messageRenderers, - commands, - setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { - sendMessageHandler = handler; - }, - setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { - appendEntryHandler = handler; - }, - setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => { - newSessionHandler = handler; - }, - setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => { - branchHandler = handler; - }, - setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { - navigateTreeHandler = handler; - }, - }; - }); + api.on("session_start", async (_event, ctx) => + runOnSession({ reason: "start", previousSessionFile: undefined }, ctx), + ); + api.on("session_switch", async (event, ctx) => + runOnSession({ reason: "switch", previousSessionFile: event.previousSessionFile }, ctx), + ); + api.on("session_branch", async (event, ctx) => + runOnSession({ reason: "branch", previousSessionFile: event.previousSessionFile }, ctx), + ); + api.on("session_tree", async (_event, ctx) => + runOnSession({ reason: "tree", previousSessionFile: undefined }, ctx), + ); + api.on("session_shutdown", async (_event, ctx) => + runOnSession({ reason: "shutdown", previousSessionFile: undefined }, ctx), + ); + }; } // Factory @@ -506,7 +497,6 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa * getApiKey: async () => process.env.MY_KEY, * systemPrompt: 'You are helpful.', * tools: [readTool, bashTool], - * hooks: [], * skills: [], * sessionManager: SessionManager.inMemory(), * }); @@ -515,17 +505,15 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); + const eventBus = options.eventBus ?? createEventBus(); // Use provided or create AuthStorage and ModelRegistry - const authStorage = options.authStorage ?? discoverAuthStorage(agentDir); - const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir); + const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir)); + const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir)); time("discoverModels"); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); time("settingsManager"); - - // Initialize discovery system with settings for provider persistence - const { initializeWithSettings } = await import("../discovery"); initializeWithSettings(settingsManager); time("initializeWithSettings"); @@ -543,17 +531,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // If session has data, try to restore model from it const defaultModelStr = existingSession.models.default; if (!model && hasExistingSession && defaultModelStr) { - const slashIdx = defaultModelStr.indexOf("/"); - if (slashIdx > 0) { - const provider = defaultModelStr.slice(0, slashIdx); - const modelId = defaultModelStr.slice(slashIdx + 1); - const restoredModel = modelRegistry.find(provider, modelId); + const parsedModel = parseModelString(defaultModelStr); + if (parsedModel) { + const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id); if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) { model = restoredModel; } - if (!model) { - modelFallbackMessage = `Could not restore model ${defaultModelStr}`; - } + } + if (!model) { + modelFallbackMessage = `Could not restore model ${defaultModelStr}`; } } @@ -561,11 +547,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!model) { const settingsDefaultModel = settingsManager.getModelRole("default"); if (settingsDefaultModel) { - const slashIdx = settingsDefaultModel.indexOf("/"); - if (slashIdx > 0) { - const provider = settingsDefaultModel.slice(0, slashIdx); - const modelId = settingsDefaultModel.slice(slashIdx + 1); - const settingsModel = modelRegistry.find(provider, modelId); + const parsedModel = parseModelString(settingsDefaultModel); + if (parsedModel) { + const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id); if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) { model = settingsModel; } @@ -629,68 +613,35 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); time("discoverContextFiles"); - // Hook runner - always created (needed for custom command context even without hooks) - let loadedHooks: LoadedHook[] = []; - if (options.hooks !== undefined) { - if (options.hooks.length > 0) { - loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - } - } else { - // Discover hooks, merging with additional paths - const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; - const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd); - time("discoverAndLoadHooks"); - for (const { path, error } of errors) { - console.error(`Failed to load hook "${path}": ${error}`); - } - loadedHooks = hooks; - } - const hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); - const sessionContext = { getSessionFile: () => sessionManager.getSessionFile() ?? null, }; - const builtInTools = - options.tools ?? - createCodingTools(cwd, options.hasUI ?? false, sessionContext, { - lspFormatOnWrite: settingsManager.getLspFormatOnWrite(), - lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(), - lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(), - editFuzzyMatch: settingsManager.getEditFuzzyMatch(), - }); - time("createCodingTools"); + const allBuiltInToolsMap = await createAllTools(cwd, sessionContext, { + lspFormatOnWrite: settingsManager.getLspFormatOnWrite(), + lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(), + lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(), + editFuzzyMatch: settingsManager.getEditFuzzyMatch(), + readAutoResizeImages: settingsManager.getImageAutoResize(), + }); + time("createAllTools"); - let customToolsResult: CustomToolsLoadResult; - if (options.customTools !== undefined) { - // Use provided custom tools - const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ - path: ct.path ?? "", - resolvedPath: ct.path ?? "", - tool: ct.tool, - })); - customToolsResult = { - tools: loadedTools, - errors: [], - setUIContext: () => {}, - }; - } else { - // Discover custom tools, merging with additional paths - const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; - customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools)); - time("discoverAndLoadCustomTools"); - for (const { path, error } of customToolsResult.errors) { - console.error(`Failed to load custom tool "${path}": ${error}`); - } - } + const initialActiveToolNames: ToolName[] = options.tools + ? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap) + : baseCodingToolNames; + const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]); // Discover MCP tools from .mcp.json files let mcpManager: MCPManager | undefined; const enableMCP = options.enableMCP ?? true; + const customTools: CustomTool[] = []; if (enableMCP) { const mcpResult = await discoverAndLoadMCPTools(cwd, { onConnecting: (serverNames) => { if (options.hasUI && serverNames.length > 0) { - process.stderr.write(chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}...\n`)); + process.stderr.write( + chalk.gray(`Connecting to MCP servers: ${serverNames.join(", ")}... +`), + ); } }, enableProjectConfig: settingsManager.getMCPProjectConfigEnabled(), @@ -710,19 +661,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} console.error(`MCP "${path}": ${error}`); } - // Merge MCP tools into custom tools result if (mcpResult.tools.length > 0) { - customToolsResult = { - ...customToolsResult, - tools: [...customToolsResult.tools, ...mcpResult.tools], - }; + // MCP tools are LoadedCustomTool, extract the tool property + customTools.push(...mcpResult.tools.map((loaded) => loaded.tool)); } } + // Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available + const geminiImageTools = await getGeminiImageTools(); + if (geminiImageTools.length > 0) { + customTools.push(...(geminiImageTools as unknown as CustomTool[])); + } + time("getGeminiImageTools"); + // Add specialized Exa web search tools if EXA_API_KEY is available const exaSettings = settingsManager.getExaSettings(); if (exaSettings.enabled && exaSettings.enableSearch) { - const { getWebSearchTools } = await import("./tools/web-search/index.js"); const exaWebSearchTools = await getWebSearchTools({ enableLinkedin: exaSettings.enableLinkedin, enableCompany: exaSettings.enableCompany, @@ -730,34 +684,83 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Filter out the base web_search (already in built-in tools), add specialized Exa tools const specializedTools = exaWebSearchTools.filter((t) => t.name !== "web_search"); if (specializedTools.length > 0) { - const loadedExaTools: LoadedCustomTool[] = specializedTools.map((tool) => ({ - path: "", - resolvedPath: "", - tool, - source: { provider: "builtin", providerName: "builtin", level: "user" }, - })); - customToolsResult = { - ...customToolsResult, - tools: [...customToolsResult.tools, ...loadedExaTools], - }; + customTools.push(...specializedTools); } time("getWebSearchTools"); } - // Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available - const geminiImageTools = await getGeminiImageTools(); - if (geminiImageTools.length > 0) { - const loadedGeminiTools: LoadedCustomTool[] = geminiImageTools.map((tool) => ({ - path: "", - resolvedPath: "", - tool: tool as unknown as CustomTool, - source: { provider: "builtin", providerName: "builtin", level: "user" }, - })); - customToolsResult = { - ...customToolsResult, - tools: [...customToolsResult.tools, ...loadedGeminiTools], + const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : []; + if (customTools.length > 0) { + inlineExtensions.push(createCustomToolsExtension(customTools)); + } + + // Load extensions (discovers from standard locations + configured paths) + let extensionsResult: LoadExtensionsResult; + if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) { + extensionsResult = { + extensions: options.preloadedExtensions, + errors: [], + setUIContext: () => {}, }; - time("getGeminiImageTools"); + } else { + // Merge CLI extension paths with settings extension paths + const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...settingsManager.getExtensionPaths()]; + extensionsResult = await discoverAndLoadExtensions( + configuredPaths, + cwd, + eventBus, + settingsManager.getDisabledExtensions(), + ); + time("discoverAndLoadExtensions"); + for (const { path, error } of extensionsResult.errors) { + console.error(`Failed to load extension "${path}": ${error}`); + } + } + + // Load inline extensions from factories + if (inlineExtensions.length > 0) { + const uiHolder: { ui: any; hasUI: boolean } = { + ui: { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return {} as any; + }, + }, + hasUI: false, + }; + for (let i = 0; i < inlineExtensions.length; i++) { + const factory = inlineExtensions[i]; + const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, ``); + extensionsResult.extensions.push(loaded); + } + const originalSetUIContext = extensionsResult.setUIContext; + extensionsResult.setUIContext = (uiContext, hasUI) => { + originalSetUIContext(uiContext, hasUI); + uiHolder.ui = uiContext; + uiHolder.hasUI = hasUI; + }; + } + + // Discover custom commands (TypeScript slash commands) + const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir }); + time("discoverCustomCommands"); + for (const { path, error } of customCommandsResult.errors) { + console.error(`Failed to load custom command "${path}": ${error}`); + } + + let extensionRunner: ExtensionRunner | undefined; + if (extensionsResult.extensions.length > 0) { + extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry); } let agent: Agent; @@ -773,90 +776,148 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, }); const toolContextStore = createToolContextStore(getSessionContext); - const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, getSessionContext); - const baseSetUIContext = customToolsResult.setUIContext; - customToolsResult = { - ...customToolsResult, - setUIContext: (uiContext, hasUI) => { - toolContextStore.setUIContext(uiContext, hasUI); - baseSetUIContext(uiContext, hasUI); + + const registeredTools = extensionRunner?.getAllRegisteredTools() ?? []; + const allCustomTools = [ + ...registeredTools, + ...(options.customTools?.map((tool) => { + const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool; + return { definition, extensionPath: "" }; + }) ?? []), + ]; + const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, () => ({ + ui: extensionRunner?.getUIContext() ?? { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return {} as any; + }, }, - }; + hasUI: extensionRunner?.getHasUI() ?? false, + cwd, + sessionManager, + modelRegistry, + model: agent.state.model, + isIdle: () => !session.isStreaming, + abort: () => { + session.abort(); + }, + hasPendingMessages: () => session.queuedMessageCount > 0, + hasQueuedMessages: () => session.queuedMessageCount > 0, + })); - let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; - - // Add rulebook tool if there are rules with descriptions (always enabled, regardless of --tools) - if (rulebookRules.length > 0) { - allToolsArray.push(createRulebookTool(rulebookRules)); + const toolRegistry = new Map(); + for (const [name, tool] of Object.entries(allBuiltInToolsMap)) { + toolRegistry.set(name, tool as AgentTool); + } + for (const tool of wrappedExtensionTools as AgentTool[]) { + toolRegistry.set(tool.name, tool); + } + + let activeToolsArray: Tool[] = [...initialActiveBuiltInTools, ...wrappedExtensionTools]; + + if (rulebookRules.length > 0) { + activeToolsArray.push(createRulebookTool(rulebookRules)); } - // Filter out hidden tools unless explicitly requested if (options.explicitTools) { const explicitSet = new Set(options.explicitTools); - allToolsArray = allToolsArray.filter((tool) => !tool.hidden || explicitSet.has(tool.name)); + activeToolsArray = activeToolsArray.filter((tool) => !tool.hidden || explicitSet.has(tool.name)); } else { - allToolsArray = allToolsArray.filter((tool) => !tool.hidden); + activeToolsArray = activeToolsArray.filter((tool) => !tool.hidden); } time("combineTools"); - // Apply bash interception to redirect common shell patterns to proper tools (if enabled) if (settingsManager.getBashInterceptorEnabled()) { - allToolsArray = applyBashInterception(allToolsArray); + activeToolsArray = applyBashInterception(activeToolsArray); } time("applyBashInterception"); - if (hookRunner) { - allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; + let wrappedToolRegistry: Map | undefined; + if (extensionRunner) { + activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner); + const allRegistryTools = Array.from(toolRegistry.values()); + const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner); + wrappedToolRegistry = new Map(); + for (const tool of wrappedAllTools) { + wrappedToolRegistry.set(tool.name, tool); + } } - let systemPrompt: string; - const defaultPrompt = buildSystemPromptInternal({ - cwd, - skills, - contextFiles, - rulebookRules, - }); - time("buildSystemPrompt"); - - if (options.systemPrompt === undefined) { - systemPrompt = defaultPrompt; - } else if (typeof options.systemPrompt === "string") { - systemPrompt = buildSystemPromptInternal({ + const rebuildSystemPrompt = (toolNames: string[]): string => { + const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap); + const extraToolDescriptions = toolNames + .filter((name) => !(name in allBuiltInToolsMap)) + .map((name) => { + const tool = toolRegistry.get(name); + if (!tool) return null; + return { name, description: tool.description || tool.label || "Custom tool" }; + }) + .filter((tool): tool is { name: string; description: string } => tool !== null); + const defaultPrompt = buildSystemPromptInternal({ cwd, skills, contextFiles, rulebookRules, - customPrompt: options.systemPrompt, + selectedTools: validToolNames, + extraToolDescriptions, + skillsSettings: settingsManager.getSkillsSettings(), }); - } else { - systemPrompt = options.systemPrompt(defaultPrompt); - } - const commandsSettings = settingsManager.getCommandsSettings(); - const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir, commandsSettings); - time("discoverSlashCommands"); + if (options.systemPrompt === undefined) { + return defaultPrompt; + } + if (typeof options.systemPrompt === "string") { + return buildSystemPromptInternal({ + cwd, + skills, + contextFiles, + rulebookRules, + selectedTools: validToolNames, + extraToolDescriptions, + skillsSettings: settingsManager.getSkillsSettings(), + customPrompt: options.systemPrompt, + }); + } + return options.systemPrompt(defaultPrompt); + }; - // Discover custom commands (TypeScript slash commands) - const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir }); - time("discoverCustomCommands"); - for (const { path, error } of customCommandsResult.errors) { - console.error(`Failed to load custom command "${path}": ${error}`); - } + const systemPrompt = rebuildSystemPrompt(initialActiveToolNames); + time("buildSystemPrompt"); + + const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir)); + time("discoverPromptTemplates"); + + const baseSetUIContext = extensionsResult.setUIContext; + extensionsResult.setUIContext = (uiContext, hasUI) => { + baseSetUIContext(uiContext, hasUI); + toolContextStore.setUIContext(uiContext, hasUI); + }; agent = new Agent({ initialState: { systemPrompt, model, thinkingLevel, - tools: allToolsArray, + tools: activeToolsArray, }, convertToLlm, - transformContext: hookRunner + transformContext: extensionRunner ? async (messages) => { - return hookRunner.emitContext(messages); + return extensionRunner.emitContext(messages); } : undefined, - queueMode: settingsManager.getQueueMode(), + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), interruptMode: settingsManager.getInterruptMode(), getToolContext: toolContextStore.getContext, getApiKey: async () => { @@ -889,12 +950,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} sessionManager, settingsManager, scopedModels: options.scopedModels, - fileCommands: slashCommands, - hookRunner, - customTools: customToolsResult.tools, + promptTemplates, + extensionRunner, customCommands: customCommandsResult.commands, skillsSettings: settingsManager.getSkillsSettings(), modelRegistry, + toolRegistry: wrappedToolRegistry ?? toolRegistry, + rebuildSystemPrompt, ttsrManager, }); time("createAgentSession"); @@ -913,7 +975,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return { session, - customToolsResult, + extensionsResult, mcpManager, modelFallbackMessage, lspServers, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 3f2aea1dc..da6a4531e 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,19 +1,16 @@ import { - appendFileSync, closeSync, createWriteStream, existsSync, fsyncSync, mkdirSync, openSync, - readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, type WriteStream, - writeFileSync, } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -23,13 +20,14 @@ import sharp from "sharp"; import { getAgentDir as getDefaultAgentDir } from "../config"; import { type BashExecutionMessage, + type CustomMessage, createBranchSummaryMessage, createCompactionSummaryMessage, - createHookMessage, + createCustomMessage, type HookMessage, } from "./messages"; -export const CURRENT_SESSION_VERSION = 2; +export const CURRENT_SESSION_VERSION = 3; export interface SessionHeader { type: "session"; @@ -75,27 +73,27 @@ export interface CompactionEntry extends SessionEntryBase { summary: string; firstKeptEntryId: string; tokensBefore: number; - /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; - /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */ - fromHook?: boolean; + /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ + fromExtension?: boolean; } export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; - /** Hook-specific data (not sent to LLM) */ + /** Extension-specific data (not sent to LLM) */ details?: T; - /** True if generated by a hook, false if pi-generated */ - fromHook?: boolean; + /** True if generated by an extension, false if pi-generated */ + fromExtension?: boolean; } /** - * Custom entry for hooks to store hook-specific data in the session. - * Use customType to identify your hook's entries. + * Custom entry for extensions to store extension-specific data in the session. + * Use customType to identify your extension's entries. * - * Purpose: Persist hook state across session reloads. On reload, hooks can + * Purpose: Persist extension state across session reloads. On reload, extensions can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). @@ -122,12 +120,12 @@ export interface TtsrInjectionEntry extends SessionEntryBase { } /** - * Custom message entry for hooks to inject messages into LLM context. - * Use customType to identify your hook's entries. + * Custom message entry for extensions to inject messages into LLM context. + * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content is converted to a user message in buildSessionContext(). - * Use details for hook-specific metadata (not sent to LLM). + * Use details for extension-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely @@ -239,8 +237,22 @@ function migrateV1ToV2(entries: FileEntry[]): void { } } -// Add future migrations here: -// function migrateV2ToV3(entries: FileEntry[]): void { ... } +/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ +function migrateV2ToV3(entries: FileEntry[]): void { + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 3; + continue; + } + + if (entry.type === "message") { + const msg = entry.message as { role?: string }; + if (msg.role === "hookMessage") { + (entry.message as { role: string }).role = "custom"; + } + } + } +} /** * Run all necessary migrations to bring entries to current version. @@ -253,7 +265,7 @@ function migrateToCurrentVersion(entries: FileEntry[]): boolean { if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); - // if (version < 3) migrateV2ToV3(entries); + if (version < 3) migrateV2ToV3(entries); return true; } @@ -380,7 +392,7 @@ export function buildSessionContext( messages.push(entry.message); } else if (entry.type === "custom_message") { messages.push( - createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), + createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), ); } else if (entry.type === "branch_summary" && entry.summary) { messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); @@ -438,7 +450,7 @@ function getDefaultSessionDir(cwd: string): string { export function loadEntriesFromFile(filePath: string): FileEntry[] { if (!existsSync(filePath)) return []; - const content = readFileSync(filePath, "utf8"); + const content = readFileSync(filePath, "utf-8"); const entries: FileEntry[] = []; const lines = content.trim().split("\n"); @@ -535,10 +547,9 @@ function getSortedSessions(sessionDir: string): RecentSessionInfo[] { return header; }; - return readdirSync(sessionDir) + return Array.from(new Bun.Glob("*.jsonl").scanSync(sessionDir)) .map((f) => { try { - if (!f.endsWith(".jsonl")) return null; const path = join(sessionDir, f); const fd = openSync(path, "r"); try { @@ -927,8 +938,8 @@ export class SessionManager { } /** Initialize with a specific session file (used by factory methods) */ - private async _initSessionFile(sessionFile: string): Promise { - await this.setSessionFile(sessionFile); + private _initSessionFile(sessionFile: string): void { + this.setSessionFile(sessionFile); } /** Initialize with a new session (used by factory methods) */ @@ -937,23 +948,25 @@ export class SessionManager { } /** Switch to a different session file (used for resume and branching) */ - async setSessionFile(sessionFile: string): Promise { - await this._closePersistWriter(); + setSessionFile(sessionFile: string): void { + void this._closePersistWriter(); this.persistError = undefined; this.persistErrorReported = false; this.sessionFile = resolve(sessionFile); if (existsSync(this.sessionFile)) { - this.fileEntries = loadEntriesFromFile(this.sessionFile); - const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; - this.sessionId = header?.id ?? nanoid(); - this.sessionTitle = header?.title; + void (async () => { + this.fileEntries = await loadEntriesFromFile(this.sessionFile!); + const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; + this.sessionId = header?.id ?? nanoid(); + this.sessionTitle = header?.title; - if (migrateToCurrentVersion(this.fileEntries)) { - await this._rewriteFile(); - } + if (migrateToCurrentVersion(this.fileEntries)) { + await this._rewriteFile(); + } - this._buildIndex(); - this.flushed = true; + this._buildIndex(); + this.flushed = true; + })(); } else { this._newSessionSync(); } @@ -1178,7 +1191,7 @@ export class SessionManager { fileHeader.title = title; lines[0] = JSON.stringify(fileHeader); const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`); - writeFileSync(tempPath, lines.join("\n")); + await Bun.write(tempPath, lines.join("\n")); const fd = openSync(tempPath, "r"); try { fsyncSync(fd); @@ -1265,7 +1278,7 @@ export class SessionManager { * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ - appendMessage(message: Message | HookMessage | BashExecutionMessage): string { + appendMessage(message: Message | CustomMessage | HookMessage | BashExecutionMessage): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.byId), @@ -1314,7 +1327,7 @@ export class SessionManager { firstKeptEntryId: string, tokensBefore: number, details?: T, - fromHook?: boolean, + fromExtension?: boolean, ): string { const entry: CompactionEntry = { type: "compaction", @@ -1325,13 +1338,13 @@ export class SessionManager { firstKeptEntryId, tokensBefore, details, - fromHook, + fromExtension, }; this._appendEntry(entry); return entry.id; } - /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */ + /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType: string, data?: unknown): string { const entry: CustomEntry = { type: "custom", @@ -1346,11 +1359,11 @@ export class SessionManager { } /** - * Append a custom message entry (for hooks) that participates in LLM context. + * Append a custom message entry (for extensions) that participates in LLM context. * @param customType Hook identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) - * @param details Optional hook-specific metadata (not sent to LLM) + * @param details Optional extension-specific metadata (not sent to LLM) * @returns Entry id */ appendCustomMessageEntry( @@ -1589,7 +1602,7 @@ export class SessionManager { * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ - branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { + branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } @@ -1602,7 +1615,7 @@ export class SessionManager { fromId: branchFromId ?? "root", summary, details, - fromHook, + fromExtension, }; this._appendEntry(entry); return entry.id; @@ -1646,9 +1659,11 @@ export class SessionManager { } if (this.persist) { - appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`); + const file = Bun.file(newSessionFile); + const writer = file.writer(); + writer.write(`${JSON.stringify(header)}\n`); for (const entry of pathWithoutLabels) { - appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); + writer.write(`${JSON.stringify(entry)}\n`); } // Write fresh label entries at the end const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; @@ -1663,11 +1678,12 @@ export class SessionManager { targetId, label, }; - appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`); + writer.write(`${JSON.stringify(labelEntry)}\n`); pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } + writer.end(); this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.sessionId = newSessionId; this._buildIndex(); @@ -1714,13 +1730,13 @@ export class SessionManager { */ static async open(path: string, sessionDir?: string): Promise { // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = loadEntriesFromFile(path); + const entries = await loadEntriesFromFile(path); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; const cwd = header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ?? resolve(path, ".."); const manager = new SessionManager(cwd, dir, true); - await manager._initSessionFile(path); + manager._initSessionFile(path); return manager; } @@ -1729,12 +1745,12 @@ export class SessionManager { * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ - static async continueRecent(cwd: string, sessionDir?: string): Promise { + static continueRecent(cwd: string, sessionDir?: string): SessionManager { const dir = sessionDir ?? getDefaultSessionDir(cwd); const mostRecent = findMostRecentSession(dir); const manager = new SessionManager(cwd, dir, true); if (mostRecent) { - await manager._initSessionFile(mostRecent); + manager._initSessionFile(mostRecent); } else { manager._initNewSession(); } @@ -1758,13 +1774,11 @@ export class SessionManager { const sessions: SessionInfo[] = []; try { - const files = readdirSync(dir) - .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(dir, f)); + const files = Array.from(new Bun.Glob("*.jsonl").scanSync(dir)).map((f) => join(dir, f)); for (const file of files) { try { - const content = readFileSync(file, "utf8"); + const content = readFileSync(file, "utf-8"); const lines = content.trim().split("\n"); if (lines.length === 0) continue; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 26f283a13..ebd86e7bd 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -42,6 +42,10 @@ export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) } +export interface ImageSettings { + autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) +} + export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off"; export interface NotificationSettings { @@ -140,7 +144,9 @@ export interface Settings { /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */ modelRoles?: Record; defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; - queueMode?: "all" | "one-at-a-time"; + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; + queueMode?: "all" | "one-at-a-time"; // legacy interruptMode?: "immediate" | "wait"; theme?: string; symbolPreset?: SymbolPreset; // default: uses theme's preset or "unicode" @@ -150,11 +156,12 @@ export interface Settings { hideThinkingBlock?: boolean; shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - hooks?: string[]; // Array of hook file paths - customTools?: string[]; // Array of custom tool file paths + doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree") + extensions?: string[]; // Array of extension file paths skills?: SkillsSettings; commands?: CommandsSettings; terminal?: TerminalSettings; + images?: ImageSettings; notifications?: NotificationSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) exa?: ExaSettings; @@ -249,13 +256,24 @@ export class SettingsManager { } try { const content = readFileSync(path, "utf-8"); - return JSON.parse(content); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings as Record); } catch (error) { console.error(`Warning: Could not read settings file ${path}: ${error}`); return {}; } } + /** Migrate old settings format to new format */ + private static migrateSettings(settings: Record): Settings { + // Migrate queueMode -> steeringMode + if ("queueMode" in settings && !("steeringMode" in settings)) { + settings.steeringMode = settings.queueMode; + delete settings.queueMode; + } + return settings as Settings; + } + private loadProjectSettings(): Settings { if (!this.cwd) return {}; @@ -270,7 +288,7 @@ export class SettingsManager { } } - return merged; + return SettingsManager.migrateSettings(merged as Record); } /** Apply additional overrides on top of current settings */ @@ -332,12 +350,21 @@ export class SettingsManager { return { ...this.settings.modelRoles }; } - getQueueMode(): "all" | "one-at-a-time" { - return this.settings.queueMode || "one-at-a-time"; + getSteeringMode(): "all" | "one-at-a-time" { + return this.settings.steeringMode || "one-at-a-time"; } - setQueueMode(mode: "all" | "one-at-a-time"): void { - this.globalSettings.queueMode = mode; + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.steeringMode = mode; + this.save(); + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.settings.followUpMode || "one-at-a-time"; + } + + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.followUpMode = mode; this.save(); } @@ -458,21 +485,12 @@ export class SettingsManager { this.save(); } - getHookPaths(): string[] { - return [...(this.settings.hooks ?? [])]; + getExtensionPaths(): string[] { + return [...(this.settings.extensions ?? [])]; } - setHookPaths(paths: string[]): void { - this.globalSettings.hooks = paths; - this.save(); - } - - getCustomToolPaths(): string[] { - return [...(this.settings.customTools ?? [])]; - } - - setCustomToolPaths(paths: string[]): void { - this.globalSettings.customTools = paths; + setExtensionPaths(paths: string[]): void { + this.globalSettings.extensions = paths; this.save(); } @@ -533,6 +551,18 @@ export class SettingsManager { this.save(); } + getImageAutoResize(): boolean { + return this.settings.images?.autoResize ?? true; + } + + setImageAutoResize(enabled: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.autoResize = enabled; + this.save(); + } + getEnabledModels(): string[] | undefined { return this.settings.enabledModels; } @@ -914,4 +944,13 @@ export class SettingsManager { this.globalSettings.statusLine.showHookStatus = show; this.save(); } + + getDoubleEscapeAction(): "branch" | "tree" { + return this.settings.doubleEscapeAction ?? "tree"; + } + + setDoubleEscapeAction(action: "branch" | "tree"): void { + this.globalSettings.doubleEscapeAction = action; + this.save(); + } } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 77af08823..b340cfad3 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -77,9 +77,12 @@ const toolDescriptions: Record = { ls: "List directory contents", lsp: "PREFERRED for semantic code queries: go-to-definition, find-all-references, hover (type info), call hierarchy. Returns precise, deterministic results. Use BEFORE grep for symbol lookups.", notebook: "Edit Jupyter notebook cells", + output: "Output structured data to the user (bypasses tool result formatting)", task: "Spawn a sub-agent to handle complex tasks", web_fetch: "Fetch and render URLs into clean text for LLM consumption", web_search: "Search the web for information", + report_finding: "Report a finding during code review", + submit_review: "Submit the final code review with all findings", }; /** @@ -231,6 +234,8 @@ export interface BuildSystemPromptOptions { customPrompt?: string; /** Tools to include in prompt. Default: [read, bash, edit, write] */ selectedTools?: ToolName[]; + /** Extra tool descriptions to include in prompt (non built-in tools). */ + extraToolDescriptions?: Array<{ name: string; description: string }>; /** Text to append to system prompt. */ appendSystemPrompt?: string; /** Skills settings for discovery. */ @@ -250,6 +255,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const { customPrompt, selectedTools, + extraToolDescriptions = [], appendSystemPrompt, skillsSettings, cwd, @@ -304,6 +310,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin } } + // Append custom tool descriptions if provided + if (extraToolDescriptions.length > 0) { + prompt += "\n\n# Additional Tools\n\n"; + prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n"); + } + // Append git context if in a git repo const gitContext = loadGitContext(resolvedCwd); if (gitContext) { @@ -335,7 +347,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Build tools list based on selected tools const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); - const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n"); + const builtInToolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n"); + const extraToolsList = + extraToolDescriptions.length > 0 + ? extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n") + : ""; + const toolsList = [builtInToolsList, extraToolsList].filter(Boolean).join("\n"); // Generate anti-bash rules (returns null if not applicable) const antiBashSection = generateAntiBashRules(tools); @@ -413,6 +430,12 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin } } + // Append custom tool descriptions if provided + if (extraToolDescriptions.length > 0) { + prompt += "\n\n# Additional Tools\n\n"; + prompt += extraToolDescriptions.map((tool) => `- ${tool.name}: ${tool.description}`).join("\n"); + } + // Append git context if in a git repo const gitContext = loadGitContext(resolvedCwd); if (gitContext) { diff --git a/packages/coding-agent/src/core/tools/context.ts b/packages/coding-agent/src/core/tools/context.ts index 403e5635e..3738ca5e7 100644 --- a/packages/coding-agent/src/core/tools/context.ts +++ b/packages/coding-agent/src/core/tools/context.ts @@ -1,21 +1,21 @@ import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import type { CustomToolContext } from "../custom-tools/types"; -import type { HookUIContext } from "../hooks/types"; +import type { ExtensionUIContext } from "../extensions/types"; declare module "@oh-my-pi/pi-agent-core" { interface AgentToolContext extends CustomToolContext { - ui?: HookUIContext; + ui?: ExtensionUIContext; hasUI?: boolean; } } export interface ToolContextStore { getContext(): AgentToolContext; - setUIContext(uiContext: HookUIContext, hasUI: boolean): void; + setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void; } export function createToolContextStore(getBaseContext: () => CustomToolContext): ToolContextStore { - let uiContext: HookUIContext | undefined; + let uiContext: ExtensionUIContext | undefined; let hasUI = false; return { diff --git a/packages/coding-agent/src/core/tools/exa/mcp-client.ts b/packages/coding-agent/src/core/tools/exa/mcp-client.ts index aaf39689f..12e087794 100644 --- a/packages/coding-agent/src/core/tools/exa/mcp-client.ts +++ b/packages/coding-agent/src/core/tools/exa/mcp-client.ts @@ -4,6 +4,8 @@ * Client for interacting with Exa MCP servers via JSON-RPC 2.0 over HTTPS. */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import type { TSchema } from "@sinclair/typebox"; import type { CustomTool } from "../../custom-tools/types"; import { logger } from "../../logger"; @@ -26,14 +28,13 @@ export async function findApiKey(): Promise { // Try loading from .env files in cwd and home const cwd = process.cwd(); - const home = process.env.HOME ?? process.env.USERPROFILE ?? "~"; + const home = homedir(); for (const dir of [cwd, home]) { const envPath = `${dir}/.env`; try { - const file = Bun.file(envPath); - if (await file.exists()) { - const content = await file.text(); + if (existsSync(envPath)) { + const content = readFileSync(envPath, "utf-8"); const match = content.match(/^EXA_API_KEY=(.+)$/m); if (match?.[1]) { return match[1].trim().replace(/^["']|["']$/g, ""); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index bcbda3c82..1a92147b6 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -21,7 +21,7 @@ export { } from "./lsp/index"; export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook"; export { createOutputTool, type OutputToolDetails, outputTool } from "./output"; -export { createReadTool, type ReadToolDetails, readTool } from "./read"; +export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read"; export { createReportFindingTool, createSubmitReviewTool, reportFindingTool, submitReviewTool } from "./review"; export { createRulebookTool, @@ -89,17 +89,22 @@ export interface CodingToolsOptions { lspFormatOnWrite?: boolean; /** Whether to accept high-confidence fuzzy matches in edit tool (default: true) */ editFuzzyMatch?: boolean; + /** Whether to auto-resize images to 2000x2000 max in read tool (default: true) */ + readAutoResizeImages?: boolean; /** Set of tool names available to the agent (for cross-tool awareness) */ availableTools?: Set; } // Factory function type -type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool; +type ToolFactory = (cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions) => Tool | Promise; // Tool definitions: static tools and their factory functions const toolDefs: Record = { ask: { tool: askTool, create: createAskTool }, - read: { tool: readTool, create: createReadTool }, + read: { + tool: readTool, + create: (cwd, _ctx, options) => createReadTool(cwd, { autoResizeImages: options?.readAutoResizeImages ?? true }), + }, bash: { tool: bashTool, create: createBashTool }, edit: { tool: editTool, @@ -145,7 +150,7 @@ export type ToolName = keyof typeof toolDefs; const uiToolNames: ToolName[] = ["ask"]; // Tool sets defined by name (base sets, without UI-only tools) -const baseCodingToolNames: ToolName[] = [ +export const baseCodingToolNames: ToolName[] = [ "read", "bash", "edit", @@ -182,15 +187,15 @@ export const allTools = Object.fromEntries(Object.entries(toolDefs).map(([name, * @param sessionContext - Optional session context for tools that need it * @param options - Options for tool configuration */ -export function createCodingTools( +export async function createCodingTools( cwd: string, hasUI = false, sessionContext?: SessionContext, options?: CodingToolsOptions, -): Tool[] { +): Promise { const names = hasUI ? [...baseCodingToolNames, ...uiToolNames] : baseCodingToolNames; const optionsWithTools = { ...options, availableTools: new Set(names) }; - return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)); + return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools))); } /** @@ -200,15 +205,15 @@ export function createCodingTools( * @param sessionContext - Optional session context for tools that need it * @param options - Options for tool configuration */ -export function createReadOnlyTools( +export async function createReadOnlyTools( cwd: string, hasUI = false, sessionContext?: SessionContext, options?: CodingToolsOptions, -): Tool[] { +): Promise { const names = hasUI ? [...baseReadOnlyToolNames, ...uiToolNames] : baseReadOnlyToolNames; const optionsWithTools = { ...options, availableTools: new Set(names) }; - return names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools)); + return Promise.all(names.map((name) => toolDefs[name].create(cwd, sessionContext, optionsWithTools))); } /** @@ -217,16 +222,20 @@ export function createReadOnlyTools( * @param sessionContext - Optional session context for tools that need it * @param options - Options for tool configuration */ -export function createAllTools( +export async function createAllTools( cwd: string, sessionContext?: SessionContext, options?: CodingToolsOptions, -): Record { +): Promise> { const names = Object.keys(toolDefs); const optionsWithTools = { ...options, availableTools: new Set(names) }; - return Object.fromEntries( - Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext, optionsWithTools)]), - ) as Record; + const entries = await Promise.all( + Object.entries(toolDefs).map(async ([name, def]) => [ + name, + await def.create(cwd, sessionContext, optionsWithTools), + ]), + ); + return Object.fromEntries(entries) as Record; } /** diff --git a/packages/coding-agent/src/core/tools/lsp/config.ts b/packages/coding-agent/src/core/tools/lsp/config.ts index a54a98763..976188774 100644 --- a/packages/coding-agent/src/core/tools/lsp/config.ts +++ b/packages/coding-agent/src/core/tools/lsp/config.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { extname, join } from "node:path"; -import { getConfigDirPaths } from "../../../config.js"; +import { getConfigDirPaths } from "../../../config"; import { createBiomeClient } from "./clients/biome-client"; import type { ServerConfig } from "./types"; @@ -618,7 +618,7 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean { // Handle glob-like patterns (e.g., "*.cabal") if (marker.includes("*")) { try { - const { globSync } = require("node:fs"); + const { globSync } = require("glob"); const matches = globSync(join(cwd, marker)); return matches.length > 0; } catch { @@ -626,7 +626,8 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean { return false; } } - return existsSync(join(cwd, marker)); + const filePath = join(cwd, marker); + return existsSync(filePath); }); } @@ -741,7 +742,7 @@ function getConfigPaths(cwd: string): string[] { * } * ``` */ -export function loadConfig(cwd: string): LspConfig { +export async function loadConfig(cwd: string): Promise { const configPaths = getConfigPaths(cwd); for (const configPath of configPaths) { diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts index d35fb774f..947334b2c 100644 --- a/packages/coding-agent/src/core/tools/lsp/index.ts +++ b/packages/coding-agent/src/core/tools/lsp/index.ts @@ -1,4 +1,5 @@ -import * as fs from "node:fs"; +import type { Dirent } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { BunFile } from "bun"; @@ -78,7 +79,7 @@ export interface LspWarmupResult { * @returns Status of each server that was started */ export async function warmupLspServers(cwd: string): Promise { - const config = loadConfig(cwd); + const config = await loadConfig(cwd); setIdleTimeout(config.idleTimeoutMs); const servers: LspWarmupResult["servers"] = []; const lspServers = getLspServers(config); @@ -173,10 +174,10 @@ async function notifyFileSaved( // Cache config per cwd to avoid repeated file I/O const configCache = new Map(); -function getConfig(cwd: string): LspConfig { +async function getConfig(cwd: string): Promise { let config = configCache.get(cwd); if (!config) { - config = loadConfig(cwd); + config = await loadConfig(cwd); setIdleTimeout(config.idleTimeoutMs); configCache.set(cwd, config); } @@ -225,9 +226,13 @@ function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: n const normalized = extensions.map((ext) => ext.toLowerCase()); const search = (dir: string, depth: number): string | null => { if (depth > maxDepth) return null; - let entries: fs.Dirent[]; + let entries: Dirent[]; try { - entries = fs.readdirSync(dir, { withFileTypes: true }); + entries = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false })).map((name) => ({ + name, + isFile: () => !existsSync(path.join(dir, name)) || Bun.file(path.join(dir, name)).type !== "directory", + isDirectory: () => existsSync(path.join(dir, name)) && Bun.file(path.join(dir, name)).type === "directory", + })) as Dirent[]; } catch { return null; } @@ -312,22 +317,22 @@ interface ProjectType { /** Detect project type from root markers */ function detectProjectType(cwd: string): ProjectType { // Check for Rust (Cargo.toml) - if (fs.existsSync(path.join(cwd, "Cargo.toml"))) { + if (existsSync(path.join(cwd, "Cargo.toml"))) { return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" }; } // Check for TypeScript (tsconfig.json) - if (fs.existsSync(path.join(cwd, "tsconfig.json"))) { + if (existsSync(path.join(cwd, "tsconfig.json"))) { return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" }; } // Check for Go (go.mod) - if (fs.existsSync(path.join(cwd, "go.mod"))) { + if (existsSync(path.join(cwd, "go.mod"))) { return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" }; } // Check for Python (pyproject.toml or pyrightconfig.json) - if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) { + if (existsSync(path.join(cwd, "pyproject.toml")) || existsSync(path.join(cwd, "pyrightconfig.json"))) { return { type: "python", command: ["pyright"], description: "Python (pyright)" }; } @@ -612,7 +617,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions return writethroughNoop; } return async (dst: string, content: string, signal?: AbortSignal, file?: BunFile) => { - const config = getConfig(cwd); + const config = await getConfig(cwd); const servers = getServersForFile(config, dst); if (servers.length === 0) { return writethroughNoop(dst, content, signal, file); @@ -708,7 +713,7 @@ export function createLspTool(cwd: string): AgentTool { while (true) { try { - const stats = await stat(current); - if (stats.isDirectory()) { - return current; + if (existsSync(current)) { + // Check if directory by trying to read it as dir + try { + await Bun.$`test -d ${current}`.quiet(); + return current; + } catch { + // Not a directory, continue + } } } catch { // Keep walking up. @@ -300,17 +304,17 @@ function convertWithMarkitdown(filePath: string): { content: string; ok: boolean return { content: "", ok: false, error: "markitdown not found" }; } - const result = spawnSync(cmd, [filePath], { - encoding: "utf-8", - timeout: 60000, - maxBuffer: 50 * 1024 * 1024, + const result = Bun.spawnSync([cmd, filePath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", }); - if (result.status === 0 && result.stdout && result.stdout.length > 0) { - return { content: result.stdout, ok: true }; + if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) { + return { content: result.stdout.toString(), ok: true }; } - return { content: "", ok: false, error: result.stderr || "Conversion failed" }; + return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" }; } const readSchema = Type.Object({ @@ -324,7 +328,13 @@ export interface ReadToolDetails { redirectedTo?: "ls"; } -export function createReadTool(cwd: string): AgentTool { +export interface ReadToolOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; +} + +export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { + const autoResizeImages = options?.autoResizeImages ?? true; const lsTool = createLsTool(cwd); return { name: "read", @@ -339,9 +349,21 @@ export function createReadTool(cwd: string): AgentTool { const absolutePath = resolveReadPath(readPath, cwd); return untilAborted(signal, async () => { - let fileStat: Awaited>; + let isDirectory = false; + let fileSize = 0; try { - fileStat = await stat(absolutePath); + if (!existsSync(absolutePath)) { + throw { code: "ENOENT" }; + } + const file = Bun.file(absolutePath); + fileSize = file.size; + // Check if directory + try { + await Bun.$`test -d ${absolutePath}`.quiet(); + isDirectory = true; + } catch { + isDirectory = false; + } } catch (error) { if (isNotFoundError(error)) { const suggestions = await findReadPathSuggestions(readPath, cwd); @@ -366,7 +388,7 @@ export function createReadTool(cwd: string): AgentTool { throw error; } - if (fileStat.isDirectory()) { + if (isDirectory) { const lsResult = await lsTool.execute(toolCallId, { path: readPath, limit }, signal); return { content: lsResult.content, @@ -374,8 +396,6 @@ export function createReadTool(cwd: string): AgentTool { }; } - await access(absolutePath, constants.R_OK); - const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); const ext = path.extname(absolutePath).toLowerCase(); @@ -385,9 +405,8 @@ export function createReadTool(cwd: string): AgentTool { if (mimeType) { // Check image file size before reading to prevent OOM during serialization - const fileStat = await stat(absolutePath); - if (fileStat.size > MAX_IMAGE_SIZE) { - const sizeStr = formatSize(fileStat.size); + if (fileSize > MAX_IMAGE_SIZE) { + const sizeStr = formatSize(fileSize); const maxStr = formatSize(MAX_IMAGE_SIZE); content = [ { @@ -397,13 +416,30 @@ export function createReadTool(cwd: string): AgentTool { ]; } else { // Read as image (binary) - const buffer = await readFile(absolutePath); - const base64 = buffer.toString("base64"); + const file = Bun.file(absolutePath); + const buffer = await file.arrayBuffer(); + const base64 = Buffer.from(buffer).toString("base64"); - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; + if (autoResizeImages) { + // Resize image if needed + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const dimensionNote = formatDimensionNote(resized); + + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } else { + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; + } } } else if (CONVERTIBLE_EXTENSIONS.has(ext)) { // Convert document via markitdown @@ -431,7 +467,8 @@ export function createReadTool(cwd: string): AgentTool { } } else { // Read as text - const textContent = await readFile(absolutePath, "utf-8"); + const file = Bun.file(absolutePath); + const textContent = await file.text(); const allLines = textContent.split("\n"); const totalFileLines = allLines.length; diff --git a/packages/coding-agent/src/core/tools/task/discovery.ts b/packages/coding-agent/src/core/tools/task/discovery.ts index 52e428696..a5e491dca 100644 --- a/packages/coding-agent/src/core/tools/task/discovery.ts +++ b/packages/coding-agent/src/core/tools/task/discovery.ts @@ -154,7 +154,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[] * * @param cwd - Current working directory for project agent discovery */ -export function discoverAgents(cwd: string): DiscoveryResult { +export async function discoverAgents(cwd: string): Promise { const resolvedCwd = path.resolve(cwd); const agentSources = Array.from(new Set(getConfigDirs("", { project: false }).map((entry) => entry.source))); @@ -167,7 +167,7 @@ export function discoverAgents(cwd: string): DiscoveryResult { })); // Get project directories by walking up from cwd (priority order) - const projectDirs = findAllNearestProjectConfigDirs("agents", resolvedCwd) + const projectDirs = (await findAllNearestProjectConfigDirs("agents", resolvedCwd)) .filter((entry) => agentSources.includes(entry.source)) .map((entry) => ({ ...entry, diff --git a/packages/coding-agent/src/core/tools/task/executor.ts b/packages/coding-agent/src/core/tools/task/executor.ts index dc1116252..35eb39635 100644 --- a/packages/coding-agent/src/core/tools/task/executor.ts +++ b/packages/coding-agent/src/core/tools/task/executor.ts @@ -5,11 +5,9 @@ * Parses JSON events for progress tracking. */ -import { spawn } from "node:child_process"; -import * as fs from "node:fs"; -import * as os from "node:os"; +import { existsSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import * as path from "node:path"; -import * as readline from "node:readline"; import { ensureArtifactsDir, getArtifactPaths } from "./artifacts"; import { resolveModelPattern } from "./model-resolver"; import { resolveOmpCommand } from "./omp-command"; @@ -171,14 +169,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise { if (!resolved) { - proc.kill("SIGTERM"); + proc.kill(15); // SIGTERM } }; if (signal) { @@ -293,9 +292,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + const processLine = (line: string) => { if (resolved) return; try { @@ -362,7 +363,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { if (!resolved) { resolved = true; - proc.kill("SIGTERM"); + proc.kill(15); // SIGTERM } }, 2000); } @@ -406,7 +407,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { - stderr += stderrDecoder.decode(chunk, { stream: true }); - }); - - // Wait for readline to finish BEFORE resolving - const exitCode = await new Promise((resolve) => { - let code: number | null = null; - let rlClosed = false; - let procClosed = false; - - const maybeResolve = () => { - if (rlClosed && procClosed) { - resolved = true; - resolve(code ?? 1); + // Read stdout asynchronously + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + processLine(line); + } } - }; + // Process remaining buffer + if (buffer.trim()) { + processLine(buffer); + } + } catch { + // Ignore read errors + } + })(); - rl.on("close", () => { - rlClosed = true; - maybeResolve(); - }); + // Capture stderr - Bun.spawn returns ReadableStream, convert to text + (async () => { + try { + const reader = proc.stderr.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + } catch { + // Ignore stderr read errors + } + })(); - proc.on("close", (c) => { - code = c; - procClosed = true; - maybeResolve(); - }); - - proc.on("error", (err) => { - stderr += `\nProcess error: ${err.message}`; - code = 1; - procClosed = true; - maybeResolve(); - }); - }); + // Wait for process to finish + resolved = true; + const exitCode = await proc.exited; // Cleanup if (signal) { @@ -479,7 +483,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + const { agents } = await discoverAgents(cwd); const lines: string[] = []; @@ -258,11 +258,11 @@ function buildDescription(cwd: string): string { /** * Create the task tool configured for a specific working directory. */ -export function createTaskTool( +export async function createTaskTool( cwd: string, sessionContext?: SessionContext, options?: TaskToolOptions, -): AgentTool { +): Promise> { const hasOutputTool = options?.availableTools?.has("output") ?? false; // Check if subagents are completely inhibited (legacy recursion prevention) if (process.env[OMP_NO_SUBAGENTS_ENV]) { @@ -288,13 +288,13 @@ export function createTaskTool( return { name: "task", label: "Task", - description: buildDescription(cwd), + description: await buildDescription(cwd), parameters: taskSchema, renderCall, renderResult, execute: async (_toolCallId, params, signal, onUpdate) => { const startTime = Date.now(); - const { agents, projectAgentsDir } = discoverAgents(cwd); + const { agents, projectAgentsDir } = await discoverAgents(cwd); const context = params.context; // Handle empty or missing tasks @@ -548,5 +548,20 @@ export function createTaskTool( }; } -// Default task tool using process.cwd() -export const taskTool = createTaskTool(process.cwd()); +// Default task tool using process.cwd() - returns a placeholder sync tool +// Real implementations should use createTaskTool() which properly initializes the tool +export const taskTool: AgentTool = { + name: "task", + label: "Task", + description: + "Launch a new agent to handle complex, multi-step tasks autonomously. (Agent discovery pending - use createTaskTool for full functionality)", + parameters: taskSchema, + execute: async () => ({ + content: [{ type: "text", text: "Task tool not properly initialized. Use createTaskTool(cwd) instead." }], + details: { + projectAgentsDir: null, + results: [], + totalDurationMs: 0, + }, + }), +}; diff --git a/packages/coding-agent/src/core/tools/task/model-resolver.ts b/packages/coding-agent/src/core/tools/task/model-resolver.ts index 3af8ba63b..b90a5d8e3 100644 --- a/packages/coding-agent/src/core/tools/task/model-resolver.ts +++ b/packages/coding-agent/src/core/tools/task/model-resolver.ts @@ -11,7 +11,6 @@ * - "omp/slow" → configured slow model from settings */ -import { spawnSync } from "node:child_process"; import { type Settings, settingsCapability } from "../../../capability/settings"; import { loadSync } from "../../../discovery"; import { resolveOmpCommand } from "./omp-command"; @@ -37,20 +36,20 @@ export function getAvailableModels(): string[] { try { const ompCommand = resolveOmpCommand(); - const result = spawnSync(ompCommand.cmd, [...ompCommand.args, "--list-models"], { - encoding: "utf-8", - timeout: 5000, - shell: ompCommand.shell, + const result = Bun.spawnSync([ompCommand.cmd, ...ompCommand.args, "--list-models"], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", }); - if (result.status !== 0 || !result.stdout) { + if (result.exitCode !== 0 || !result.stdout) { cachedModels = []; cacheExpiry = now + CACHE_TTL_MS; return cachedModels; } // Parse output: skip header line, extract provider/model - const lines = result.stdout.trim().split("\n"); + const lines = result.stdout.toString().trim().split("\n"); cachedModels = lines .slice(1) // Skip header .map((line) => { diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts index 96056832e..c30e44a4f 100644 --- a/packages/coding-agent/src/core/tools/web-fetch.ts +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -1,6 +1,4 @@ -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as os from "node:os"; +import { tmpdir } from "node:os"; import * as path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -197,18 +195,15 @@ function exec( args: string[], options?: { timeout?: number; input?: string | Buffer }, ): { stdout: string; stderr: string; ok: boolean } { - const timeout = (options?.timeout ?? DEFAULT_TIMEOUT) * 1000; - const result = spawnSync(cmd, args, { - encoding: options?.input instanceof Buffer ? "buffer" : "utf-8", - timeout, - maxBuffer: MAX_BYTES, - input: options?.input, - shell: true, + const result = Bun.spawnSync([cmd, ...args], { + stdin: options?.input ? (options.input as any) : "ignore", + stdout: "pipe", + stderr: "pipe", }); return { stdout: result.stdout?.toString() ?? "", stderr: result.stderr?.toString() ?? "", - ok: result.status === 0, + ok: result.exitCode === 0, }; } @@ -217,8 +212,12 @@ function exec( */ function hasCommand(cmd: string): boolean { const checkCmd = isWindows ? "where" : "which"; - const result = spawnSync(checkCmd, [cmd], { encoding: "utf-8", shell: true }); - return result.status === 0; + const result = Bun.spawnSync([checkCmd, cmd], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return result.exitCode === 0; } /** @@ -299,26 +298,27 @@ function looksLikeHtml(content: string): boolean { /** * Convert binary file to markdown using markitdown */ -function convertWithMarkitdown( +async function convertWithMarkitdown( content: Buffer, extensionHint: string, timeout: number, -): { content: string; ok: boolean } { +): Promise<{ content: string; ok: boolean }> { if (!hasCommand("markitdown")) { return { content: "", ok: false }; } // Write to temp file with extension hint const ext = extensionHint || ".bin"; - const tmpFile = path.join(os.tmpdir(), `omp-convert-${Date.now()}${ext}`); + const tmpDir = tmpdir(); + const tmpFile = path.join(tmpDir, `omp-convert-${Date.now()}${ext}`); try { - fs.writeFileSync(tmpFile, content); + await Bun.write(tmpFile, content); const result = exec("markitdown", [tmpFile], { timeout }); return { content: result.stdout, ok: result.ok }; } finally { try { - fs.unlinkSync(tmpFile); + await Bun.$`rm ${tmpFile}`.quiet(); } catch {} } } @@ -531,10 +531,11 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string { /** * Render HTML to text using lynx */ -function renderWithLynx(html: string, timeout: number): { content: string; ok: boolean } { - const tmpFile = path.join(os.tmpdir(), `omp-render-${Date.now()}.html`); +async function renderWithLynx(html: string, timeout: number): Promise<{ content: string; ok: boolean }> { + const tmpDir = tmpdir(); + const tmpFile = path.join(tmpDir, `omp-render-${Date.now()}.html`); try { - fs.writeFileSync(tmpFile, html); + await Bun.write(tmpFile, html); // Convert path to file URL (handles Windows paths correctly) const normalizedPath = tmpFile.replace(/\\/g, "/"); const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`; @@ -542,7 +543,7 @@ function renderWithLynx(html: string, timeout: number): { content: string; ok: b return { content: result.stdout, ok: result.ok }; } finally { try { - fs.unlinkSync(tmpFile); + await Bun.$`rm ${tmpFile}`.quiet(); } catch {} } } @@ -1752,7 +1753,7 @@ async function handleArxiv(url: string, timeout: number): Promise 500) { md += `---\n\n## Full Paper\n\n${converted.content}\n`; notes.push("PDF converted via markitdown"); @@ -1835,7 +1836,7 @@ async function handleIacr(url: string, timeout: number): Promise 500) { md += `---\n\n## Full Paper\n\n${converted.content}\n`; notes.push("PDF converted via markitdown"); @@ -1992,7 +1993,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr const binary = await fetchBinary(finalUrl, timeout); if (binary.ok) { const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint; - const converted = convertWithMarkitdown(binary.buffer, ext, timeout); + const converted = await convertWithMarkitdown(binary.buffer, ext, timeout); if (converted.ok && converted.content.trim().length > 50) { notes.push(`Converted with markitdown`); const output = finalizeOutput(converted.content); @@ -2174,7 +2175,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr }; } - const lynxResult = renderWithLynx(rawContent, timeout); + const lynxResult = await renderWithLynx(rawContent, timeout); if (!lynxResult.ok) { notes.push("lynx failed"); const output = finalizeOutput(rawContent); @@ -2198,7 +2199,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean = false): Pr const binary = await fetchBinary(docUrl, timeout); if (binary.ok) { const ext = getExtensionHint(docUrl, binary.contentDisposition); - const converted = convertWithMarkitdown(binary.buffer, ext, timeout); + const converted = await convertWithMarkitdown(binary.buffer, ext, timeout); if (converted.ok && converted.content.trim().length > lynxResult.content.length) { notes.push(`Extracted and converted document: ${docUrl}`); const output = finalizeOutput(converted.content); diff --git a/packages/coding-agent/src/core/tools/web-search/providers/exa.ts b/packages/coding-agent/src/core/tools/web-search/providers/exa.ts index d39cb198f..38c75200d 100644 --- a/packages/coding-agent/src/core/tools/web-search/providers/exa.ts +++ b/packages/coding-agent/src/core/tools/web-search/providers/exa.ts @@ -5,6 +5,8 @@ * Returns structured search results with optional content extraction. */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import type { WebSearchResponse, WebSearchSource } from "../types"; const EXA_API_URL = "https://api.exa.ai/search"; @@ -27,10 +29,9 @@ export interface ExaSearchParams { async function parseEnvFile(filePath: string): Promise> { const result: Record = {}; try { - const file = Bun.file(filePath); - if (!(await file.exists())) return result; + if (!existsSync(filePath)) return result; - const content = await file.text(); + const content = readFileSync(filePath, "utf-8"); for (const line of content.split("\n")) { let trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; @@ -57,8 +58,8 @@ async function parseEnvFile(filePath: string): Promise> { return result; } -function getHomeDir(): string | null { - return process.env.HOME ?? process.env.USERPROFILE ?? null; +function getHomeDir(): string { + return homedir(); } /** Find EXA_API_KEY from environment or .env files */ diff --git a/packages/coding-agent/src/core/voice-supervisor.ts b/packages/coding-agent/src/core/voice-supervisor.ts index ad02b9198..012c605a0 100644 --- a/packages/coding-agent/src/core/voice-supervisor.ts +++ b/packages/coding-agent/src/core/voice-supervisor.ts @@ -764,17 +764,17 @@ export class VoiceSupervisor { continue; } - const micRms = rms16le(value); - const now = Date.now(); - const playbackActive = now - this.lastPlaybackAt < PLAYBACK_ACTIVE_WINDOW_MS; + const micRms = rms16le(value); + const now = Date.now(); + const playbackActive = now - this.lastPlaybackAt < PLAYBACK_ACTIVE_WINDOW_MS; - // Echo suppression: only skip if playback is active AND mic is very quiet relative to playback - // This prevents feedback loops while allowing user to speak over the assistant - if (playbackActive && micRms < MIC_NOISE_FLOOR && micRms < this.lastPlaybackRms / ECHO_SUPPRESSION_RATIO) { - continue; - } + // Echo suppression: only skip if playback is active AND mic is very quiet relative to playback + // This prevents feedback loops while allowing user to speak over the assistant + if (playbackActive && micRms < MIC_NOISE_FLOOR && micRms < this.lastPlaybackRms / ECHO_SUPPRESSION_RATIO) { + continue; + } - // Send all audio to realtime API - let semantic_vad handle turn detection + // Send all audio to realtime API - let semantic_vad handle turn detection const buffer = toArrayBuffer(value); if (buffer.byteLength === 0) continue; try { @@ -855,9 +855,9 @@ export class VoiceSupervisor { }; } else if ("write" in stdin && typeof (stdin as { write?: unknown }).write === "function") { const sink = stdin as unknown as { - write: (chunk: Uint8Array) => void | number | Promise; - end?: () => void | number | Promise; - close?: () => void | number | Promise; + write: (chunk: Uint8Array) => undefined | number | Promise; + end?: () => undefined | number | Promise; + close?: () => undefined | number | Promise; }; this.playbackWriter = { write: async (chunk) => { diff --git a/packages/coding-agent/src/discovery/builtin.ts b/packages/coding-agent/src/discovery/builtin.ts index 006baea42..a7942337b 100644 --- a/packages/coding-agent/src/discovery/builtin.ts +++ b/packages/coding-agent/src/discovery/builtin.ts @@ -5,9 +5,10 @@ * .pi is an alias for backwards compatibility. */ -import { basename, dirname, join } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "path"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension"; +import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; import { type Hook, hookCapability } from "../capability/hook"; import { registerProvider } from "../capability/index"; import { type Instruction, instructionCapability } from "../capability/instruction"; @@ -22,7 +23,9 @@ import { type CustomTool, toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; import { createSourceMeta, + discoverExtensionModulePaths, expandEnvVarsDeep, + getExtensionNameFromPath, loadFilesFromDir, parseFrontmatter, parseJSON, @@ -361,6 +364,77 @@ registerProvider(promptCapability.id, { load: loadPrompts, }); +// Extension Modules +function loadExtensionModules(ctx: LoadContext): LoadResult { + const items: ExtensionModule[] = []; + const warnings: string[] = []; + + const resolveExtensionPath = (rawPath: string): string => { + if (rawPath.startsWith("~/")) { + return join(ctx.home, rawPath.slice(2)); + } + if (rawPath.startsWith("~")) { + return join(ctx.home, rawPath.slice(1)); + } + if (isAbsolute(rawPath)) { + return rawPath; + } + return resolve(ctx.cwd, rawPath); + }; + + const addExtensionPath = (extPath: string, level: "user" | "project"): void => { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level, + _source: createSourceMeta(PROVIDER_ID, extPath, level), + }); + }; + + for (const { dir, level } of getConfigDirs(ctx)) { + const extensionsDir = join(dir, "extensions"); + const discovered = discoverExtensionModulePaths(ctx, extensionsDir); + for (const extPath of discovered) { + addExtensionPath(extPath, level); + } + + const settingsPath = join(dir, "settings.json"); + const settingsContent = ctx.fs.readFile(settingsPath); + if (settingsContent) { + const settingsData = parseJSON<{ extensions?: unknown }>(settingsContent); + const extensions = settingsData?.extensions; + if (Array.isArray(extensions)) { + for (const entry of extensions) { + if (typeof entry !== "string") { + warnings.push(`Invalid extension path in ${settingsPath}: ${String(entry)}`); + continue; + } + const resolvedPath = resolveExtensionPath(entry); + if (ctx.fs.isDir(resolvedPath)) { + for (const extPath of discoverExtensionModulePaths(ctx, resolvedPath)) { + addExtensionPath(extPath, level); + } + } else if (ctx.fs.isFile(resolvedPath)) { + addExtensionPath(resolvedPath, level); + } else { + warnings.push(`Extension path not found: ${resolvedPath}`); + } + } + } + } + } + + return { items, warnings }; +} + +registerProvider(extensionModuleCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: DESCRIPTION, + priority: PRIORITY, + load: loadExtensionModules, +}); + // Extensions function loadExtensions(ctx: LoadContext): LoadResult { const items: Extension[] = []; diff --git a/packages/coding-agent/src/discovery/claude.ts b/packages/coding-agent/src/discovery/claude.ts index 96b72e73a..85aeb7397 100644 --- a/packages/coding-agent/src/discovery/claude.ts +++ b/packages/coding-agent/src/discovery/claude.ts @@ -5,8 +5,9 @@ * Priority: 80 (tool-specific, below builtin but above shared standards) */ -import { dirname, join, sep } from "node:path"; +import { dirname, join, sep } from "path"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; +import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; import { type Hook, hookCapability } from "../capability/hook"; import { registerProvider } from "../capability/index"; import { type MCPServer, mcpCapability } from "../capability/mcp"; @@ -19,7 +20,9 @@ import type { LoadContext, LoadResult } from "../capability/types"; import { calculateDepth, createSourceMeta, + discoverExtensionModulePaths, expandEnvVarsDeep, + getExtensionNameFromPath, loadFilesFromDir, parseFrontmatter, parseJSON, @@ -292,6 +295,41 @@ function loadSkills(ctx: LoadContext): LoadResult { return { items, warnings }; } +// ============================================================================= +// Extension Modules +// ============================================================================= + +function loadExtensionModules(ctx: LoadContext): LoadResult { + const items: ExtensionModule[] = []; + const warnings: string[] = []; + + const userBase = getUserClaude(ctx); + const userExtensionsDir = join(userBase, "extensions"); + for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "user", + _source: createSourceMeta(PROVIDER_ID, extPath, "user"), + }); + } + + const projectBase = getProjectClaude(ctx); + if (projectBase) { + const projectExtensionsDir = join(projectBase, "extensions"); + for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "project", + _source: createSourceMeta(PROVIDER_ID, extPath, "project"), + }); + } + } + + return { items, warnings }; +} + // ============================================================================= // Slash Commands // ============================================================================= @@ -582,6 +620,14 @@ registerProvider(skillCapability.id, { load: loadSkills, }); +registerProvider(extensionModuleCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: "Load extension modules from .claude/extensions", + priority: PRIORITY, + load: loadExtensionModules, +}); + registerProvider(slashCommandCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index a49ad1b21..383899493 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -7,10 +7,11 @@ * User directory: ~/.codex */ -import { join } from "node:path"; +import { join } from "path"; import { parse as parseToml } from "smol-toml"; import type { ContextFile } from "../capability/context-file"; import { contextFileCapability } from "../capability/context-file"; +import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; import type { Hook } from "../capability/hook"; import { hookCapability } from "../capability/hook"; import { registerProvider } from "../capability/index"; @@ -27,7 +28,14 @@ import { slashCommandCapability } from "../capability/slash-command"; import type { CustomTool } from "../capability/tool"; import { toolCapability } from "../capability/tool"; import type { LoadContext, LoadResult } from "../capability/types"; -import { createSourceMeta, loadFilesFromDir, parseFrontmatter, SOURCE_PATHS } from "./helpers"; +import { + createSourceMeta, + discoverExtensionModulePaths, + getExtensionNameFromPath, + loadFilesFromDir, + parseFrontmatter, + SOURCE_PATHS, +} from "./helpers"; const PROVIDER_ID = "codex"; const DISPLAY_NAME = "OpenAI Codex"; @@ -251,6 +259,42 @@ function loadSkills(ctx: LoadContext): LoadResult { return { items, warnings }; } +// ============================================================================= +// Extension Modules (extensions/) +// ============================================================================= + +function loadExtensionModules(ctx: LoadContext): LoadResult { + const items: ExtensionModule[] = []; + const warnings: string[] = []; + + // User level: ~/.codex/extensions/ + const userExtensionsDir = join(ctx.home, SOURCE_PATHS.codex.userBase, "extensions"); + for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "user", + _source: createSourceMeta(PROVIDER_ID, extPath, "user"), + }); + } + + // Project level: .codex/extensions/ + const codexDir = ctx.fs.walkUp(".codex", { dir: true }); + if (codexDir) { + const projectExtensionsDir = join(codexDir, "extensions"); + for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "project", + _source: createSourceMeta(PROVIDER_ID, extPath, "project"), + }); + } + } + + return { items, warnings }; +} + // ============================================================================= // Slash Commands (commands/) // ============================================================================= @@ -530,6 +574,14 @@ registerProvider(skillCapability.id, { load: loadSkills, }); +registerProvider(extensionModuleCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: "Load extension modules from ~/.codex/extensions and .codex/extensions/", + priority: PRIORITY, + load: loadExtensionModules, +}); + registerProvider(slashCommandCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, diff --git a/packages/coding-agent/src/discovery/gemini.ts b/packages/coding-agent/src/discovery/gemini.ts index 0550b4622..3a6f9eba6 100644 --- a/packages/coding-agent/src/discovery/gemini.ts +++ b/packages/coding-agent/src/discovery/gemini.ts @@ -16,15 +16,25 @@ * - settings: From settings.json */ -import { dirname, join, sep } from "node:path"; +import { dirname, join, sep } from "path"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type Extension, type ExtensionManifest, extensionCapability } from "../capability/extension"; +import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; import { registerProvider } from "../capability/index"; import { type MCPServer, mcpCapability } from "../capability/mcp"; import { type Settings, settingsCapability } from "../capability/settings"; import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt"; import type { LoadContext, LoadResult } from "../capability/types"; -import { calculateDepth, createSourceMeta, expandEnvVarsDeep, getProjectPath, getUserPath, parseJSON } from "./helpers"; +import { + calculateDepth, + createSourceMeta, + discoverExtensionModulePaths, + expandEnvVarsDeep, + getExtensionNameFromPath, + getProjectPath, + getUserPath, + parseJSON, +} from "./helpers"; const PROVIDER_ID = "gemini"; const DISPLAY_NAME = "Gemini CLI"; @@ -236,6 +246,41 @@ function loadExtensionsFromDir( return { items, warnings }; } +// ============================================================================= +// Extension Modules +// ============================================================================= + +function loadExtensionModules(ctx: LoadContext): LoadResult { + const items: ExtensionModule[] = []; + const warnings: string[] = []; + + const userExtensionsDir = getUserPath(ctx, "gemini", "extensions"); + if (userExtensionsDir) { + for (const extPath of discoverExtensionModulePaths(ctx, userExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "user", + _source: createSourceMeta(PROVIDER_ID, extPath, "user"), + }); + } + } + + const projectExtensionsDir = getProjectPath(ctx, "gemini", "extensions"); + if (projectExtensionsDir) { + for (const extPath of discoverExtensionModulePaths(ctx, projectExtensionsDir)) { + items.push({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "project", + _source: createSourceMeta(PROVIDER_ID, extPath, "project"), + }); + } + } + + return { items, warnings }; +} + // ============================================================================= // Settings // ============================================================================= @@ -359,6 +404,14 @@ registerProvider(extensionCapability.id, { load: loadExtensions, }); +registerProvider(extensionModuleCapability.id, { + id: PROVIDER_ID, + displayName: DISPLAY_NAME, + description: "Load extension modules from ~/.gemini/extensions/ and .gemini/extensions/", + priority: PRIORITY, + load: loadExtensionModules, +}); + registerProvider(settingsCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 79f2dee7f..7a656ef6b 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -2,7 +2,7 @@ * Shared helpers for discovery providers. */ -import { join, resolve } from "node:path"; +import { join, resolve } from "path"; import { parse as parseYAML } from "yaml"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; @@ -247,3 +247,102 @@ export function parseJSON(content: string): T | null { export function calculateDepth(cwd: string, targetDir: string, separator: string): number { return cwd.split(separator).length - targetDir.split(separator).length; } + +interface ExtensionModuleManifest { + extensions?: string[]; +} + +function readExtensionModuleManifest(ctx: LoadContext, packageJsonPath: string): ExtensionModuleManifest | null { + const content = ctx.fs.readFile(packageJsonPath); + if (!content) return null; + + const pkg = parseJSON<{ omp?: ExtensionModuleManifest; pi?: ExtensionModuleManifest }>(content); + const manifest = pkg?.omp ?? pkg?.pi; + if (manifest && typeof manifest === "object") { + return manifest; + } + return null; +} + +function isExtensionModuleFile(name: string): boolean { + return name.endsWith(".ts") || name.endsWith(".js"); +} + +/** + * Discover extension module entry points in a directory. + * + * Discovery rules: + * 1. Direct files: `extensions/*.ts` or `*.js` → load + * 2. Subdirectory with index: `extensions//index.ts` or `index.js` → load + * 3. Subdirectory with package.json: `extensions//package.json` with "omp"/"pi" field → load declared paths + * + * No recursion beyond one level. Complex packages must use package.json manifest. + */ +export function discoverExtensionModulePaths(ctx: LoadContext, dir: string): string[] { + if (!ctx.fs.isDir(dir)) { + return []; + } + + const discovered: string[] = []; + + for (const name of ctx.fs.readDir(dir)) { + if (name.startsWith(".")) continue; + + const entryPath = join(dir, name); + + // 1. Direct files: *.ts or *.js + if (ctx.fs.isFile(entryPath) && isExtensionModuleFile(name)) { + discovered.push(entryPath); + continue; + } + + // 2 & 3. Subdirectories + if (ctx.fs.isDir(entryPath)) { + // Check for package.json with "omp"/"pi" field first + const packageJsonPath = join(entryPath, "package.json"); + if (ctx.fs.isFile(packageJsonPath)) { + const manifest = readExtensionModuleManifest(ctx, packageJsonPath); + if (manifest?.extensions && Array.isArray(manifest.extensions)) { + for (const extPath of manifest.extensions) { + const resolvedExtPath = resolve(entryPath, extPath); + if (ctx.fs.isFile(resolvedExtPath)) { + discovered.push(resolvedExtPath); + } + } + continue; + } + } + + // Check for index.ts or index.js + const indexTs = join(entryPath, "index.ts"); + const indexJs = join(entryPath, "index.js"); + if (ctx.fs.isFile(indexTs)) { + discovered.push(indexTs); + } else if (ctx.fs.isFile(indexJs)) { + discovered.push(indexJs); + } + } + } + + return discovered; +} + +/** + * Derive a stable extension name from a path. + */ +export function getExtensionNameFromPath(extensionPath: string): string { + const base = extensionPath.replace(/\\/g, "/").split("/").pop() ?? extensionPath; + + if (base === "index.ts" || base === "index.js") { + const parts = extensionPath.replace(/\\/g, "/").split("/"); + const parent = parts[parts.length - 2]; + return parent ?? base; + } + + const dot = base.lastIndexOf("."); + if (dot > 0) { + return base.slice(0, dot); + } + + return base; +} diff --git a/packages/coding-agent/src/discovery/index.ts b/packages/coding-agent/src/discovery/index.ts index fc1df91d0..7678bf4e3 100644 --- a/packages/coding-agent/src/discovery/index.ts +++ b/packages/coding-agent/src/discovery/index.ts @@ -8,6 +8,7 @@ // Import capability definitions (ensures capabilities are defined before providers register) import "../capability/context-file"; import "../capability/extension"; +import "../capability/extension-module"; import "../capability/hook"; import "../capability/instruction"; import "../capability/mcp"; @@ -34,6 +35,7 @@ import "./mcp-json"; export type { ContextFile } from "../capability/context-file"; export type { Extension, ExtensionManifest } from "../capability/extension"; +export type { ExtensionModule } from "../capability/extension-module"; export type { Hook } from "../capability/hook"; // Re-export the main API from capability registry export { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 5243f9534..9574363eb 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -60,6 +60,8 @@ export type { RenderResultOptions, } from "./core/custom-tools/index"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index"; +// Extension types +export type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "./core/extensions/types"; export type * from "./core/hooks/index"; // Hook system types and type guards export { @@ -75,6 +77,8 @@ export { export { type Logger, logger } from "./core/logger"; export { convertToLlm } from "./core/messages"; export { ModelRegistry } from "./core/model-registry"; +// Prompt templates +export type { PromptTemplate } from "./core/prompt-templates"; // SDK for programmatic usage export { type BuildSystemPromptOptions, @@ -96,16 +100,12 @@ export { // Discovery discoverAuthStorage, discoverContextFiles, - discoverCustomTools, - discoverHooks, + discoverCustomTSCommands, + discoverExtensions, + discoverMCPServers, discoverModels, + discoverPromptTemplates, discoverSkills, - discoverSlashCommands, - type FileSlashCommand, - // Hook types - type HookAPI, - type HookContext, - type HookFactory, loadSettings, // Pre-built tools (use process.cwd()) readOnlyTools, @@ -134,6 +134,7 @@ export { } from "./core/session-manager"; export { type CompactionSettings, + type ImageSettings, type LspSettings, type RetrySettings, type Settings, @@ -151,6 +152,8 @@ export { type SkillFrontmatter, type SkillWarning, } from "./core/skills"; +// Slash commands +export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands"; // Tools export { type BashToolDetails, @@ -179,7 +182,7 @@ export { main } from "./main"; // UI components for hooks and custom tools export { BorderedLoader } from "./modes/interactive/components/bordered-loader"; // Theme utilities for custom tools -export { getMarkdownTheme } from "./modes/interactive/theme/theme"; +export { getMarkdownTheme, getSettingsListTheme, type Theme } from "./modes/interactive/theme/theme"; // TypeBox helper for string enums (convenience for custom tools) import { type TSchema, Type } from "@sinclair/typebox"; diff --git a/packages/coding-agent/src/lib/worktree/collapse.ts b/packages/coding-agent/src/lib/worktree/collapse.ts index c864bb184..90a6a6c80 100644 --- a/packages/coding-agent/src/lib/worktree/collapse.ts +++ b/packages/coding-agent/src/lib/worktree/collapse.ts @@ -172,7 +172,7 @@ export async function collapse( await applyDiff(diff, dst.path); if (!options?.keepSource) { - await remove(src.path); + await remove(src.path, { force: true }); } return stats; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e67cfe7f7..b8bd1af7c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -15,9 +15,8 @@ import { selectSession } from "./cli/session-picker"; import { parseUpdateArgs, printUpdateHelp, runUpdateCommand } from "./cli/update-cli"; import { findConfigFile, getModelsPath, VERSION } from "./config"; import type { AgentSession } from "./core/agent-session"; -import type { LoadedCustomTool } from "./core/custom-tools/index"; import { exportFromFile } from "./core/export-html/index"; -import type { HookUIContext } from "./core/index"; +import type { ExtensionUIContext } from "./core/index"; import type { ModelRegistry } from "./core/model-registry"; import { parseModelPattern, resolveModelScope, type ScopedModel } from "./core/model-resolver"; import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk"; @@ -26,7 +25,7 @@ import { SettingsManager } from "./core/settings-manager"; import { resolvePromptInput } from "./core/system-prompt"; import { printTimings, time } from "./core/timings"; import { allTools } from "./core/tools/index"; -import { runMigrations } from "./migrations"; +import { runMigrations, showDeprecationWarnings } from "./migrations"; import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; @@ -59,22 +58,13 @@ async function runInteractiveMode( migratedProviders: string[], versionCheckPromise: Promise, initialMessages: string[], - customTools: LoadedCustomTool[], - setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void, + setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined, initialMessage?: string, initialImages?: ImageContent[], fdPath: string | undefined = undefined, ): Promise { - const mode = new InteractiveMode( - session, - version, - changelogMarkdown, - customTools, - setToolUIContext, - lspServers, - fdPath, - ); + const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, fdPath); await mode.init(); @@ -127,7 +117,10 @@ async function runInteractiveMode( } } -async function prepareInitialMessage(parsed: Args): Promise<{ +async function prepareInitialMessage( + parsed: Args, + autoResizeImages: boolean, +): Promise<{ initialMessage?: string; initialImages?: ImageContent[]; }> { @@ -135,7 +128,7 @@ async function prepareInitialMessage(parsed: Args): Promise<{ return {}; } - const { text, images } = await processFileArguments(parsed.fileArgs); + const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages }); let initialMessage: string; if (parsed.messages.length > 0) { @@ -215,6 +208,7 @@ async function buildSessionOptions( scopedModels: ScopedModel[], sessionManager: SessionManager | undefined, modelRegistry: ModelRegistry, + settingsManager: SettingsManager, ): Promise { const options: CreateAgentSessionOptions = {}; @@ -229,7 +223,7 @@ async function buildSessionOptions( // Model from CLI (--model) - uses same fuzzy matching as --models if (parsed.model) { - const available = await modelRegistry.getAvailable(); + const available = modelRegistry.getAvailable(); const { model, warning } = parseModelPattern(parsed.model, available); if (warning) { console.warn(chalk.yellow(`Warning: ${warning}`)); @@ -276,16 +270,20 @@ async function buildSessionOptions( // Skills if (parsed.noSkills) { options.skills = []; + } else if (parsed.skills && parsed.skills.length > 0) { + // Override includeSkills in settingsManager for this session + settingsManager.applyOverrides({ + skills: { + ...settingsManager.getSkillsSettings(), + includeSkills: parsed.skills, + }, + }); } - // Additional hook paths from CLI - if (parsed.hooks && parsed.hooks.length > 0) { - options.additionalHookPaths = parsed.hooks; - } - - // Additional custom tool paths from CLI - if (parsed.customTools && parsed.customTools.length > 0) { - options.additionalCustomToolPaths = parsed.customTools; + // Additional extension paths from CLI + const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])]; + if (cliExtensionPaths.length > 0) { + options.additionalExtensionPaths = cliExtensionPaths; } return options; @@ -320,12 +318,12 @@ export async function main(args: string[]) { return; } - // Run migrations - const { migratedAuthProviders: migratedProviders } = runMigrations(); + // Run migrations (pass cwd for project-local migrations) + const { migratedAuthProviders: migratedProviders, deprecationWarnings } = await runMigrations(process.cwd()); // Create AuthStorage and ModelRegistry upfront - const authStorage = discoverAuthStorage(); - const modelRegistry = discoverModels(authStorage); + const authStorage = await discoverAuthStorage(); + const modelRegistry = await discoverModels(authStorage); time("discoverModels"); const parsed = parseArgs(args); @@ -366,14 +364,13 @@ export async function main(args: string[]) { } const cwd = process.cwd(); - const { initialMessage, initialImages } = await prepareInitialMessage(parsed); + const settingsManager = SettingsManager.create(cwd); + time("SettingsManager.create"); + const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); time("prepareInitialMessage"); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - const settingsManager = SettingsManager.create(cwd); - time("SettingsManager.create"); - // Initialize discovery system with settings for provider persistence const { initializeWithSettings } = await import("./discovery"); initializeWithSettings(settingsManager); @@ -392,6 +389,11 @@ export async function main(args: string[]) { initTheme(settingsManager.getTheme(), isInteractive, settingsManager.getSymbolPreset()); time("initTheme"); + // Show deprecation warnings in interactive mode + if (isInteractive && deprecationWarnings.length > 0) { + await showDeprecationWarnings(deprecationWarnings); + } + let scopedModels: ScopedModel[] = []; const modelPatterns = parsed.models ?? settingsManager.getEnabledModels(); if (modelPatterns && modelPatterns.length > 0) { @@ -420,9 +422,16 @@ export async function main(args: string[]) { sessionManager = await SessionManager.open(selectedPath); } - const sessionOptions = await buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry); + const sessionOptions = await buildSessionOptions( + parsed, + scopedModels, + sessionManager, + modelRegistry, + settingsManager, + ); sessionOptions.authStorage = authStorage; sessionOptions.modelRegistry = modelRegistry; + sessionOptions.settingsManager = settingsManager; sessionOptions.hasUI = isInteractive; // Handle CLI --api-key as runtime override (not persisted) @@ -435,9 +444,25 @@ export async function main(args: string[]) { } time("buildSessionOptions"); - const { session, customToolsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions); + const { session, extensionsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions); time("createAgentSession"); + // Re-parse CLI args with extension flags and apply values + if (session.extensionRunner) { + const extFlags = session.extensionRunner.getFlags(); + if (extFlags.size > 0) { + const flagDefs = new Map(); + for (const [name, flag] of extFlags) { + flagDefs.set(name, { type: flag.type }); + } + const reparsed = parseArgs(args, flagDefs); + for (const [name, value] of reparsed.unknownFlags) { + session.extensionRunner.setFlagValue(name, value); + } + } + } + time("applyExtensionFlags"); + if (!isInteractive && !session.model) { console.error(chalk.red("No models available.")); console.error(chalk.yellow("\nSet an API key environment variable:")); @@ -489,8 +514,7 @@ export async function main(args: string[]) { migratedProviders, versionCheckPromise, parsed.messages, - customToolsResult.tools, - customToolsResult.setUIContext, + extensionsResult.setUIContext, lspServers, initialMessage, initialImages, diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index ad338cd95..24bebddd4 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -4,22 +4,28 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import chalk from "chalk"; import { getAgentDir } from "./config"; /** * Migrate PI_* environment variables to OMP_* equivalents. * If PI_XX is set and OMP_XX is not, set OMP_XX to PI_XX's value. * This provides backwards compatibility for users with existing PI_* env vars. + * + * @returns Array of PI_* env var names that were migrated */ -export function migrateEnvVars(): void { +export function migrateEnvVars(): string[] { + const migrated: string[] = []; for (const [key, value] of Object.entries(process.env)) { if (key.startsWith("PI_") && value !== undefined) { const ompKey = `OMP_${key.slice(3)}`; // PI_FOO -> OMP_FOO if (process.env[ompKey] === undefined) { process.env[ompKey] = value; + migrated.push(key); } } } + return migrated; } /** @@ -122,9 +128,7 @@ export function migrateSessionsFromAgentRoot(): void { const correctDir = join(agentDir, "sessions", safePath); // Create directory if needed - if (!existsSync(correctDir)) { - mkdirSync(correctDir, { recursive: true }); - } + mkdirSync(correctDir, { recursive: true }); // Move the file const fileName = file.split("/").pop() || file.split("\\").pop(); @@ -142,15 +146,41 @@ export function migrateSessionsFromAgentRoot(): void { /** * Run all migrations. Called once on startup. * + * @param _cwd - Current working directory (reserved for future project-local migrations) * @returns Object with migration results */ -export function runMigrations(): { migratedAuthProviders: string[] } { +export async function runMigrations(_cwd: string): Promise<{ + migratedAuthProviders: string[]; + deprecationWarnings: string[]; +}> { // First: migrate env vars (before anything else reads them) - migrateEnvVars(); + const migratedEnvVars = migrateEnvVars(); // Then: run data migrations const migratedAuthProviders = migrateAuthToAuthJson(); migrateSessionsFromAgentRoot(); - return { migratedAuthProviders }; + // Collect deprecation warnings + const deprecationWarnings: string[] = []; + if (migratedEnvVars.length > 0) { + for (const envVar of migratedEnvVars) { + const ompVar = `OMP_${envVar.slice(3)}`; + deprecationWarnings.push(`${envVar} is deprecated. Use ${ompVar} instead.`); + } + } + + return { migratedAuthProviders, deprecationWarnings }; +} + +/** + * Display deprecation warnings to the user in interactive mode. + * + * @param warnings - Array of deprecation warning messages + */ +export async function showDeprecationWarnings(warnings: string[]): Promise { + console.log(chalk.yellow("\n⚠ Deprecation Warnings:")); + for (const warning of warnings) { + console.log(chalk.yellow(` • ${warning}`)); + } + console.log(); } diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index fd6c19bd6..5d782f1e3 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -29,12 +29,14 @@ export class BashExecutionComponent extends Container { private contentContainer: Container; private ui: TUI; - constructor(command: string, ui: TUI) { + constructor(command: string, ui: TUI, excludeFromContext = false) { super(); this.command = command; this.ui = ui; - const borderColor = (str: string) => theme.fg("bashMode", str); + // Use dim border for excluded-from-context commands (!! prefix) + const colorKey = excludeFromContext ? "dim" : "bashMode"; + const borderColor = (str: string) => theme.fg(colorKey, str); // Add spacer this.addChild(new Spacer(1)); @@ -47,13 +49,13 @@ export class BashExecutionComponent extends Container { this.addChild(this.contentContainer); // Command header - const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0); + const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); this.contentContainer.addChild(header); // Loader this.loader = new Loader( ui, - (spinner) => theme.fg("bashMode", spinner), + (spinner) => theme.fg(colorKey, spinner), (text) => theme.fg("muted", text), `Running${theme.format.ellipsis} (esc to cancel)`, getSymbolTheme().spinnerFrames, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 39f6eb3b5..d08bf1aea 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -14,6 +14,8 @@ import { isEscape, isShiftCtrlP, isShiftTab, + type KeyId, + matchesKey, } from "@oh-my-pi/pi-tui"; /** @@ -37,6 +39,30 @@ export class CustomEditor extends Editor { /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */ public onCtrlV?: () => Promise; + /** Custom key handlers from extensions */ + private customKeyHandlers = new Map void>(); + + /** + * Register a custom key handler. Extensions use this for shortcuts. + */ + setCustomKeyHandler(key: KeyId, handler: () => void): void { + this.customKeyHandlers.set(key, handler); + } + + /** + * Remove a custom key handler. + */ + removeCustomKeyHandler(key: KeyId): void { + this.customKeyHandlers.delete(key); + } + + /** + * Clear all custom key handlers. + */ + clearCustomKeyHandlers(): void { + this.customKeyHandlers.clear(); + } + handleInput(data: string): void { if (isCapsLock(data) && this.onCapsLock) { this.onCapsLock(); @@ -73,6 +99,12 @@ export class CustomEditor extends Editor { return; } + // Intercept Ctrl+Y for role-based model cycling + if (isCtrlY(data) && this.onCtrlY) { + this.onCtrlY(); + return; + } + // Intercept Ctrl+L for model selector if (isCtrlL(data) && this.onCtrlL) { this.onCtrlL(); @@ -131,6 +163,14 @@ export class CustomEditor extends Editor { return; } + // Check custom key handlers (extensions) + for (const [keyId, handler] of this.customKeyHandlers) { + if (matchesKey(data, keyId)) { + handler(); + return; + } + } + // Pass to parent for normal handling super.handleInput(data); } diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts new file mode 100644 index 000000000..b1bb8c43b --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -0,0 +1,95 @@ +import type { TextContent } from "@oh-my-pi/pi-ai"; +import type { Component } from "@oh-my-pi/pi-tui"; +import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui"; +import type { MessageRenderer } from "../../../core/extensions/types"; +import type { CustomMessage } from "../../../core/messages"; +import { getMarkdownTheme, theme } from "../theme/theme"; + +/** + * Component that renders a custom message entry from extensions. + * Uses distinct styling to differentiate from user messages. + */ +export class CustomMessageComponent extends Container { + private message: CustomMessage; + private customRenderer?: MessageRenderer; + private box: Box; + private customComponent?: Component; + private _expanded = false; + + constructor(message: CustomMessage, customRenderer?: MessageRenderer) { + super(); + this.message = message; + this.customRenderer = customRenderer; + + this.addChild(new Spacer(1)); + + // Create box with custom background (used for default rendering) + this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + + this.rebuild(); + } + + setExpanded(expanded: boolean): void { + if (this._expanded !== expanded) { + this._expanded = expanded; + this.rebuild(); + } + } + + private rebuild(): void { + // Remove previous content component + if (this.customComponent) { + this.removeChild(this.customComponent); + this.customComponent = undefined; + } + this.removeChild(this.box); + + // Try custom renderer first - it handles its own styling + if (this.customRenderer) { + try { + const component = this.customRenderer(this.message, { expanded: this._expanded }, theme); + if (component) { + this.customComponent = component; + this.addChild(component); + return; + } + } catch { + // Fall through to default rendering + } + } + + // Default rendering uses our box + this.addChild(this.box); + this.box.clear(); + + // Default rendering: label + content + const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); + this.box.addChild(new Text(label, 0, 0)); + this.box.addChild(new Spacer(1)); + + // Extract text content + let text: string; + if (typeof this.message.content === "string") { + text = this.message.content; + } else { + text = this.message.content + .filter((c): c is TextContent => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + + // Limit lines when collapsed + if (!this._expanded) { + const lines = text.split("\n"); + if (lines.length > 5) { + text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`; + } + } + + this.box.addChild( + new Markdown(text, 0, 0, getMarkdownTheme(), { + color: (value: string) => theme.fg("customMessageText", value), + }), + ); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts index 787ff7a1e..1282de739 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/extension-list.ts @@ -230,6 +230,8 @@ export class ExtensionList implements Component { private getKindIcon(kind: ExtensionKind): string { switch (kind) { + case "extension-module": + return theme.icon.extensionTool; case "skill": return theme.icon.extensionSkill; case "tool": @@ -316,6 +318,7 @@ export class ExtensionList implements Component { } const kindOrder: ExtensionKind[] = [ + "extension-module", "skill", "tool", "slash-command", @@ -347,6 +350,8 @@ export class ExtensionList implements Component { private getKindLabel(kind: ExtensionKind): string { switch (kind) { + case "extension-module": + return "Extension Modules"; case "skill": return "Skills"; case "tool": diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts index 0708973bc..259bf6e82 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/inspector-panel.ts @@ -4,7 +4,6 @@ * Shows name, description, origin, status, and kind-specific preview. */ -import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import { theme } from "../../theme/theme"; @@ -98,16 +97,22 @@ export class InspectorPanel implements Component { lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); try { - const content = readFileSync(path, "utf-8"); - const fileLines = content.split("\n").slice(0, 20); + const content = Bun.file(path).text(); + // Note: async call to sync context - will show empty on first render + // This is acceptable for preview which can populate on next render + if (typeof content === "object" && "then" in content) { + content.then((text: string) => { + const fileLines = text.split("\n").slice(0, 20); - for (const line of fileLines) { - const highlighted = this.highlightMarkdown(line); - lines.push(truncateToWidth(highlighted, width - 2)); - } + for (const line of fileLines) { + const highlighted = this.highlightMarkdown(line); + lines.push(truncateToWidth(highlighted, width - 2)); + } - if (content.split("\n").length > 20) { - lines.push(theme.fg("dim", "(truncated at line 20)")); + if (text.split("\n").length > 20) { + lines.push(theme.fg("dim", "(truncated at line 20)")); + } + }); } } catch (err) { lines.push(theme.fg("error", `Failed to read file: ${err instanceof Error ? err.message : String(err)}`)); @@ -261,6 +266,7 @@ export class InspectorPanel implements Component { private getKindBadge(kind: string): string { const kindColors: Record = { + "extension-module": "accent", skill: "accent", rule: "success", tool: "warning", @@ -296,7 +302,7 @@ export class InspectorPanel implements Component { private shortenPath(path: string): string { const home = homedir(); - if (path.startsWith(home)) { + if (home && path.startsWith(home)) { return `~${path.slice(home.length)}`; } diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts index 341130c9a..a65b1cba1 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/state-manager.ts @@ -4,6 +4,7 @@ */ import type { ContextFile } from "../../../../capability/context-file"; +import type { ExtensionModule } from "../../../../capability/extension-module"; import type { Hook } from "../../../../capability/hook"; import type { MCPServer } from "../../../../capability/mcp"; import type { Prompt } from "../../../../capability/prompt"; @@ -128,6 +129,15 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi // Capability may not be registered } + // Load extension modules + try { + const modules = loadSync("extension-modules", loadOpts); + const nativeModules = modules.all.filter((module) => module._source.provider === "native"); + addItems(nativeModules, "extension-module"); + } catch { + // Capability may not be registered + } + // Load MCP servers try { const mcps = loadSync("mcps", loadOpts); @@ -394,6 +404,8 @@ export function applyFilter(extensions: Extension[], query: string): Extension[] */ function getKindDisplayName(kind: ExtensionKind): string { switch (kind) { + case "extension-module": + return "Extension Modules"; case "skill": return "Skills"; case "rule": diff --git a/packages/coding-agent/src/modes/interactive/components/extensions/types.ts b/packages/coding-agent/src/modes/interactive/components/extensions/types.ts index 92b102b5b..04285c79e 100644 --- a/packages/coding-agent/src/modes/interactive/components/extensions/types.ts +++ b/packages/coding-agent/src/modes/interactive/components/extensions/types.ts @@ -8,6 +8,7 @@ import type { SourceMeta } from "../../../../capability/types"; * Extension kinds matching capability types. */ export type ExtensionKind = + | "extension-module" | "skill" | "rule" | "tool" diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts new file mode 100644 index 000000000..95556dd71 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -0,0 +1,324 @@ +import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs"; +import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { dirname, join } from "path"; +import type { AgentSession } from "../../../core/agent-session"; +import { theme } from "../theme/theme"; + +/** + * Sanitize text for display in a single-line status. + * Removes newlines, tabs, carriage returns, and other control characters. + */ +function sanitizeStatusText(text: string): string { + // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces + return text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim(); +} + +/** + * Find the git root directory by walking up from cwd. + * Returns the path to .git/HEAD if found, null otherwise. + */ +function findGitHeadPath(): string | null { + let dir = process.cwd(); + while (true) { + const gitHeadPath = join(dir, ".git", "HEAD"); + if (existsSync(gitHeadPath)) { + return gitHeadPath; + } + const parent = dirname(dir); + if (parent === dir) { + // Reached filesystem root + return null; + } + dir = parent; + } +} + +/** + * Footer component that shows pwd, token stats, and context usage + */ +export class FooterComponent implements Component { + private session: AgentSession; + private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name + private gitWatcher: FSWatcher | null = null; + private onBranchChange: (() => void) | null = null; + private autoCompactEnabled: boolean = true; + private extensionStatuses: Map = new Map(); + + constructor(session: AgentSession) { + this.session = session; + } + + setAutoCompactEnabled(enabled: boolean): void { + this.autoCompactEnabled = enabled; + } + + /** + * Set extension status text to display in the footer. + * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width. + * ANSI escape codes for styling are preserved. + * @param key - Unique key to identify this status + * @param text - Status text, or undefined to clear + */ + setExtensionStatus(key: string, text: string | undefined): void { + if (text === undefined) { + this.extensionStatuses.delete(key); + } else { + this.extensionStatuses.set(key, text); + } + } + + /** + * Set up a file watcher on .git/HEAD to detect branch changes. + * Call the provided callback when branch changes. + */ + watchBranch(onBranchChange: () => void): void { + this.onBranchChange = onBranchChange; + this.setupGitWatcher(); + } + + private setupGitWatcher(): void { + // Clean up existing watcher + if (this.gitWatcher) { + this.gitWatcher.close(); + this.gitWatcher = null; + } + + const gitHeadPath = findGitHeadPath(); + if (!gitHeadPath) { + return; + } + + try { + this.gitWatcher = watch(gitHeadPath, () => { + this.cachedBranch = undefined; // Invalidate cache + if (this.onBranchChange) { + this.onBranchChange(); + } + }); + } catch { + // Silently fail if we can't watch + } + } + + /** + * Clean up the file watcher + */ + dispose(): void { + if (this.gitWatcher) { + this.gitWatcher.close(); + this.gitWatcher = null; + } + } + + invalidate(): void { + // Invalidate cached branch so it gets re-read on next render + this.cachedBranch = undefined; + } + + /** + * Get current git branch by reading .git/HEAD directly. + * Returns null if not in a git repo, branch name otherwise. + */ + private getCurrentBranch(): string | null { + // Return cached value if available + if (this.cachedBranch !== undefined) { + return this.cachedBranch; + } + + try { + const gitHeadPath = findGitHeadPath(); + if (!gitHeadPath) { + this.cachedBranch = null; + return null; + } + const content = readFileSync(gitHeadPath, "utf8").trim(); + + if (content.startsWith("ref: refs/heads/")) { + // Normal branch: extract branch name + this.cachedBranch = content.slice(16); + } else { + // Detached HEAD state + this.cachedBranch = "detached"; + } + } catch { + // Not in a git repo or error reading file + this.cachedBranch = null; + } + + return this.cachedBranch; + } + + render(width: number): string[] { + const state = this.session.state; + + // Calculate cumulative usage from ALL session entries (not just post-compaction messages) + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + + for (const entry of this.session.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; + } + } + + // Get last assistant message for context percentage calculation (skip aborted messages) + const lastAssistantMessage = state.messages + .slice() + .reverse() + .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; + + // Calculate context percentage from last message (input + output + cacheRead + cacheWrite) + const contextTokens = lastAssistantMessage + ? lastAssistantMessage.usage.input + + lastAssistantMessage.usage.output + + lastAssistantMessage.usage.cacheRead + + lastAssistantMessage.usage.cacheWrite + : 0; + const contextWindow = state.model?.contextWindow || 0; + const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; + const contextPercent = contextPercentValue.toFixed(1); + + // Format token counts (similar to web-ui) + const formatTokens = (count: number): string => { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; + }; + + // Replace home directory with ~ + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) { + pwd = `~${pwd.slice(home.length)}`; + } + + // Add git branch if available + const branch = this.getCurrentBranch(); + if (branch) { + pwd = `${pwd} (${branch})`; + } + + // Truncate path if too long to fit width + if (pwd.length > width) { + const half = Math.floor(width / 2) - 2; + if (half > 0) { + const start = pwd.slice(0, half); + const end = pwd.slice(-(half - 1)); + pwd = `${start}...${end}`; + } else { + pwd = pwd.slice(0, Math.max(1, width)); + } + } + + // Build stats line + const statsParts = []; + if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); + if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); + if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); + if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + + // Show cost with "(sub)" indicator if using OAuth subscription + const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; + if (totalCost || usingSubscription) { + const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; + statsParts.push(costStr); + } + + // Colorize context percentage based on usage + let contextPercentStr: string; + const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; + const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; + if (contextPercentValue > 90) { + contextPercentStr = theme.fg("error", contextPercentDisplay); + } else if (contextPercentValue > 70) { + contextPercentStr = theme.fg("warning", contextPercentDisplay); + } else { + contextPercentStr = contextPercentDisplay; + } + statsParts.push(contextPercentStr); + + let statsLeft = statsParts.join(" "); + + // Add model name on the right side, plus thinking level if model supports it + const modelName = state.model?.id || "no-model"; + + // Add thinking level hint if model supports reasoning and thinking is enabled + let rightSide = modelName; + if (state.model?.reasoning) { + const thinkingLevel = state.thinkingLevel || "off"; + if (thinkingLevel !== "off") { + rightSide = `${modelName} • ${thinkingLevel}`; + } + } + + let statsLeftWidth = visibleWidth(statsLeft); + const rightSideWidth = visibleWidth(rightSide); + + // If statsLeft is too wide, truncate it + if (statsLeftWidth > width) { + // Truncate statsLeft to fit width (no room for right side) + const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, ""); + statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`; + statsLeftWidth = visibleWidth(statsLeft); + } + + // Calculate available space for padding (minimum 2 spaces between stats and model) + const minPadding = 2; + const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; + + let statsLine: string; + if (totalNeeded <= width) { + // Both fit - add padding to right-align model + const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); + statsLine = statsLeft + padding + rightSide; + } else { + // Need to truncate right side + const availableForRight = width - statsLeftWidth - minPadding; + if (availableForRight > 3) { + // Truncate to fit (strip ANSI codes for length calculation, then truncate raw string) + const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, ""); + const truncatedPlain = plainRightSide.substring(0, availableForRight); + // For simplicity, just use plain truncated version (loses color, but fits) + const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length); + statsLine = statsLeft + padding + truncatedPlain; + } else { + // Not enough space for right side at all + statsLine = statsLeft; + } + } + + // Apply dim to each part separately. statsLeft may contain color codes (for context %) + // that end with a reset, which would clear an outer dim wrapper. So we dim the parts + // before and after the colored section independently. + const dimStatsLeft = theme.fg("dim", statsLeft); + const remainder = statsLine.slice(statsLeft.length); // padding + rightSide + const dimRemainder = theme.fg("dim", remainder); + + const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; + + // Add extension statuses on a single line, sorted by key alphabetically + if (this.extensionStatuses.size > 0) { + const sortedStatuses = Array.from(this.extensionStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => sanitizeStatusText(text)); + const statusLine = sortedStatuses.join(" "); + // Truncate to terminal width with dim ellipsis for consistency with footer style + lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + } + + return lines; + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts index 092f828cc..d7c87793b 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts @@ -3,7 +3,7 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; import { DynamicBorder } from "./dynamic-border"; @@ -83,8 +83,8 @@ export class HookSelectorComponent extends Container { this.onSelectCallback(selected); } } - // Escape - else if (isEscape(keyData)) { + // Escape or Ctrl+C + else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 83f538696..22c9d66b5 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -6,6 +6,7 @@ import { isArrowLeft, isArrowRight, isArrowUp, + isCtrlC, isEnter, isEscape, isShiftTab, @@ -202,7 +203,7 @@ export class ModelSelectorComponent extends Container { })); } else { // Refresh to pick up any changes to models.json - this.modelRegistry.refresh(); + await this.modelRegistry.refresh(); // Check for models.json errors const loadError = this.modelRegistry.getError(); @@ -212,7 +213,7 @@ export class ModelSelectorComponent extends Container { // Load available models (built-in models still work even if models.json failed) try { - const availableModels = await this.modelRegistry.getAvailable(); + const availableModels = this.modelRegistry.getAvailable(); models = availableModels.map((model: Model) => ({ provider: model.provider, id: model.id, @@ -490,8 +491,8 @@ export class ModelSelectorComponent extends Container { return; } - // Escape - close selector - if (isEscape(keyData)) { + // Escape or Ctrl+C - close selector + if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); return; } @@ -527,8 +528,8 @@ export class ModelSelectorComponent extends Container { return; } - // Escape - close menu only - if (isEscape(keyData)) { + // Escape or Ctrl+C - close menu only + if (isEscape(keyData) || isCtrlC(keyData)) { this.closeMenu(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 2a0062149..c2821cf99 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,5 +1,5 @@ import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai"; -import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; +import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui"; import type { AuthStorage } from "../../../core/auth-storage"; import { theme } from "../theme/theme"; import { DynamicBorder } from "./dynamic-border"; @@ -128,8 +128,8 @@ export class OAuthSelectorComponent extends Container { this.updateList(); } } - // Escape - else if (isEscape(keyData)) { + // Escape or Ctrl+C + else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts index 7b8f979a9..f6673ce30 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -116,21 +116,40 @@ export const SETTINGS_DEFS: SettingDef[] = [ set: (sm, v) => sm.setNotificationOnComplete(v as NotificationMethod), }, { - id: "queueMode", + id: "autoResizeImages", + tab: "config", + type: "boolean", + label: "Auto-resize images", + description: "Resize large images to 2000x2000 max for better model compatibility", + get: (sm) => sm.getImageAutoResize(), + set: (sm, v) => sm.setImageAutoResize(v), + }, + { + id: "steeringMode", tab: "config", type: "enum", - label: "Queue mode", + label: "Steering mode", description: "How to process queued messages while agent is working", values: ["one-at-a-time", "all"], - get: (sm) => sm.getQueueMode(), - set: (sm, v) => sm.setQueueMode(v as "all" | "one-at-a-time"), // Also handled in session + get: (sm) => sm.getSteeringMode(), + set: (sm, v) => sm.setSteeringMode(v as "all" | "one-at-a-time"), // Also handled in session + }, + { + id: "followUpMode", + tab: "config", + type: "enum", + label: "Follow-up mode", + description: "How to drain follow-up messages after a turn completes", + values: ["one-at-a-time", "all"], + get: (sm) => sm.getFollowUpMode(), + set: (sm, v) => sm.setFollowUpMode(v as "one-at-a-time" | "all"), // Also handled in session }, { id: "interruptMode", tab: "config", type: "enum", label: "Interrupt mode", - description: "When to process queued messages: immediately (interrupt tools) or wait for turn to complete", + description: "When steering messages interrupt tool execution", values: ["immediate", "wait"], get: (sm) => sm.getInterruptMode(), set: (sm, v) => sm.setInterruptMode(v as "immediate" | "wait"), // Also handled in session @@ -153,6 +172,16 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getCollapseChangelog(), set: (sm, v) => sm.setCollapseChangelog(v), }, + { + id: "doubleEscapeAction", + tab: "config", + type: "enum", + label: "Double-escape action", + description: "Action when pressing Escape twice with empty editor", + values: ["tree", "branch"], + get: (sm) => sm.getDoubleEscapeAction(), + set: (sm, v) => sm.setDoubleEscapeAction(v as "branch" | "tree"), + }, { id: "bashInterceptor", tab: "config", @@ -379,7 +408,7 @@ export const SETTINGS_DEFS: SettingDef[] = [ id: "statusLineShowHooks", tab: "status", type: "boolean", - label: "Show hook status", + label: "Show extension status", description: "Display hook status messages below status line", get: (sm) => sm.getStatusLineShowHookStatus(), set: (sm, v) => sm.setStatusLineShowHookStatus(v), diff --git a/packages/coding-agent/src/modes/interactive/components/status-line.ts b/packages/coding-agent/src/modes/interactive/components/status-line.ts index 4684fff64..6e07eee2d 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-line.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-line.ts @@ -1,8 +1,7 @@ -import { execSync } from "node:child_process"; -import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs"; -import { dirname, join } from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { type FSWatcher, watch } from "fs"; +import { dirname, join } from "path"; import type { AgentSession } from "../../../core/agent-session"; import type { StatusLineSegmentOptions, StatusLineSettings } from "../../../core/settings-manager"; import { theme } from "../theme/theme"; @@ -23,11 +22,11 @@ function sanitizeStatusText(text: string): string { } /** Find the git root directory by walking up from cwd */ -function findGitHeadPath(): string | null { +async function findGitHeadPath(): Promise { let dir = process.cwd(); while (true) { const gitHeadPath = join(dir, ".git", "HEAD"); - if (existsSync(gitHeadPath)) { + if (await Bun.file(gitHeadPath).exists()) { return gitHeadPath; } const parent = dirname(dir); @@ -98,19 +97,20 @@ export class StatusLineComponent implements Component { this.gitWatcher = null; } - const gitHeadPath = findGitHeadPath(); - if (!gitHeadPath) return; + findGitHeadPath().then((gitHeadPath) => { + if (!gitHeadPath) return; - try { - this.gitWatcher = watch(gitHeadPath, () => { - this.cachedBranch = undefined; - if (this.onBranchChange) { - this.onBranchChange(); - } - }); - } catch { - // Silently fail - } + try { + this.gitWatcher = watch(gitHeadPath, () => { + this.cachedBranch = undefined; + if (this.onBranchChange) { + this.onBranchChange(); + } + }); + } catch { + // Silently fail + } + }); } dispose(): void { @@ -129,24 +129,27 @@ export class StatusLineComponent implements Component { return this.cachedBranch; } - try { - const gitHeadPath = findGitHeadPath(); + // Note: synchronous call to async function - will return undefined on first call + // This is acceptable since it's a cached value that will update on next render + findGitHeadPath().then(async (gitHeadPath) => { if (!gitHeadPath) { this.cachedBranch = null; - return null; + return; } - const content = readFileSync(gitHeadPath, "utf8").trim(); + try { + const content = (await Bun.file(gitHeadPath).text()).trim(); - if (content.startsWith("ref: refs/heads/")) { - this.cachedBranch = content.slice(16); - } else { - this.cachedBranch = "detached"; + if (content.startsWith("ref: refs/heads/")) { + this.cachedBranch = content.slice(16); + } else { + this.cachedBranch = "detached"; + } + } catch { + this.cachedBranch = null; } - } catch { - this.cachedBranch = null; - } + }); - return this.cachedBranch; + return this.cachedBranch ?? null; } private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { @@ -156,12 +159,19 @@ export class StatusLineComponent implements Component { } try { - const output = execSync("git status --porcelain 2>/dev/null", { - encoding: "utf8", - timeout: 1000, - stdio: ["pipe", "pipe", "pipe"], + const result = Bun.spawnSync(["git", "status", "--porcelain"], { + stdout: "pipe", + stderr: "pipe", }); + if (!result.success) { + this.cachedGitStatus = null; + this.gitStatusLastFetch = now; + return null; + } + + const output = result.stdout.toString("utf8"); + let staged = 0; let unstaged = 0; let untracked = 0; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 0b32fd902..19228e533 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,4 +1,5 @@ -import * as os from "node:os"; +import { homedir } from "node:os"; +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import { Box, Container, @@ -11,10 +12,10 @@ import { type TUI, } from "@oh-my-pi/pi-tui"; import stripAnsi from "strip-ansi"; -import type { CustomTool } from "../../../core/custom-tools/types"; import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff"; import { toolRenderers } from "../../../core/tools/renderers"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate"; +import { convertToPng } from "../../../utils/image-convert"; import { sanitizeBinaryOutput } from "../../../utils/shell"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme"; import { renderDiff } from "./diff"; @@ -312,8 +313,8 @@ function formatArgsPreview( * Convert absolute path to tilde notation if it's in home directory */ function shortenPath(path: string): string { - const home = os.homedir(); - if (path.startsWith(home)) { + const home = homedir(); + if (home && path.startsWith(home)) { return `~${path.slice(home.length)}`; } return path; @@ -343,7 +344,7 @@ export class ToolExecutionComponent extends Container { private expanded = false; private showImages: boolean; private isPartial = true; - private customTool?: CustomTool; + private tool?: AgentTool; private ui: TUI; private cwd: string; private result?: { @@ -354,6 +355,8 @@ export class ToolExecutionComponent extends Container { // Cached edit diff preview (computed when args arrive, before tool executes) private editDiffPreview?: EditDiffResult | EditDiffError; private editDiffArgsKey?: string; // Track which args the preview is for + // Cached converted images for Kitty protocol (which requires PNG), keyed by index + private convertedImages: Map = new Map(); // Spinner animation for partial task results private spinnerFrame = 0; private spinnerInterval: ReturnType | null = null; @@ -362,7 +365,7 @@ export class ToolExecutionComponent extends Container { toolName: string, args: any, options: ToolExecutionOptions = {}, - customTool: CustomTool | undefined, + tool: AgentTool | undefined, ui: TUI, cwd: string = process.cwd(), ) { @@ -370,7 +373,7 @@ export class ToolExecutionComponent extends Container { this.toolName = toolName; this.args = args; this.showImages = options.showImages ?? true; - this.customTool = customTool; + this.tool = tool; this.ui = ui; this.cwd = cwd; @@ -382,7 +385,8 @@ export class ToolExecutionComponent extends Container { // Use Box for custom tools, bash, or built-in tools that have renderers const hasRenderer = toolName in toolRenderers; - if (customTool || toolName === "bash" || hasRenderer) { + const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult); + if (hasCustomRenderer || toolName === "bash" || hasRenderer) { this.addChild(this.contentBox); } else { this.addChild(this.contentText); @@ -449,6 +453,39 @@ export class ToolExecutionComponent extends Container { this.isPartial = isPartial; this.updateSpinnerAnimation(); this.updateDisplay(); + // Convert non-PNG images to PNG for Kitty protocol (async) + this.maybeConvertImagesForKitty(); + } + + /** + * Convert non-PNG images to PNG for Kitty graphics protocol. + * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display. + */ + private maybeConvertImagesForKitty(): void { + const caps = getCapabilities(); + // Only needed for Kitty protocol + if (caps.images !== "kitty") return; + if (!this.result) return; + + const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; + + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; + if (!img.data || !img.mimeType) continue; + // Skip if already PNG or already converted + if (img.mimeType === "image/png") continue; + if (this.convertedImages.has(i)) continue; + + // Convert async + const index = i; + convertToPng(img.data, img.mimeType).then((converted) => { + if (converted) { + this.convertedImages.set(index, converted); + this.updateDisplay(); + this.ui.requestRender(); + } + }); + } } /** @@ -499,17 +536,23 @@ export class ToolExecutionComponent extends Container { : (text: string) => theme.bg("toolSuccessBg", text); // Check for custom tool rendering - if (this.customTool) { + if (this.tool && (this.tool.renderCall || this.tool.renderResult)) { + const tool = this.tool; // Custom tools use Box for flexible component rendering this.contentBox.setBgFn(bgFn); this.contentBox.clear(); // Render call component - if (this.customTool.renderCall) { + if (tool.renderCall) { try { - const callComponent = this.customTool.renderCall(this.args, theme); + const callComponent = tool.renderCall(this.args, theme); if (callComponent) { - this.contentBox.addChild(callComponent); + // Ensure component has invalidate() method for Component interface + const component = callComponent as any; + if (!component.invalidate) { + component.invalidate = () => {}; + } + this.contentBox.addChild(component); } } catch { // Fall back to default on error @@ -521,15 +564,20 @@ export class ToolExecutionComponent extends Container { } // Render result component if we have a result - if (this.result && this.customTool.renderResult) { + if (this.result && tool.renderResult) { try { - const resultComponent = this.customTool.renderResult( + const resultComponent = tool.renderResult( { content: this.result.content as any, details: this.result.details }, { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame }, theme, ); if (resultComponent) { - this.contentBox.addChild(resultComponent); + // Ensure component has invalidate() method for Component interface + const component = resultComponent as any; + if (!component.invalidate) { + component.invalidate = () => {}; + } + this.contentBox.addChild(component); } } catch { // Fall back to showing raw output on error @@ -560,7 +608,12 @@ export class ToolExecutionComponent extends Container { try { const callComponent = renderer.renderCall(this.args, theme); if (callComponent) { - this.contentBox.addChild(callComponent); + // Ensure component has invalidate() method for Component interface + const component = callComponent as any; + if (!component.invalidate) { + component.invalidate = () => {}; + } + this.contentBox.addChild(component); } } catch { // Fall back to default on error @@ -576,7 +629,12 @@ export class ToolExecutionComponent extends Container { theme, ); if (resultComponent) { - this.contentBox.addChild(resultComponent); + // Ensure component has invalidate() method for Component interface + const component = resultComponent as any; + if (!component.invalidate) { + component.invalidate = () => {}; + } + this.contentBox.addChild(component); } } catch { // Fall back to showing raw output on error @@ -606,14 +664,25 @@ export class ToolExecutionComponent extends Container { const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || []; const caps = getCapabilities(); - for (const img of imageBlocks) { + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; if (caps.images && this.showImages && img.data && img.mimeType) { + // Use converted PNG for Kitty protocol if available + const converted = this.convertedImages.get(i); + const imageData = converted?.data ?? img.data; + const imageMimeType = converted?.mimeType ?? img.mimeType; + + // For Kitty, skip non-PNG images that haven't been converted yet + if (caps.images === "kitty" && imageMimeType !== "image/png") { + continue; + } + const spacer = new Spacer(1); this.addChild(spacer); this.imageSpacers.push(spacer); const imageComponent = new Image( - img.data, - img.mimeType, + imageData, + imageMimeType, { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, { maxWidthCells: 60 }, ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 165379474..f19571749 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -25,10 +25,10 @@ import { } from "@oh-my-pi/pi-tui"; import { getAuthPath, getDebugLogPath } from "../../config"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; -import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index"; -import type { HookUIContext } from "../../core/hooks/index"; -import { createCompactionSummaryMessage } from "../../core/messages"; +import type { ExtensionUIContext } from "../../core/extensions/index"; +import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages"; import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager"; +import { loadSlashCommands } from "../../core/slash-commands"; import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../../core/terminal-notify"; import { generateSessionTitle, setTerminalTitle } from "../../core/title-generator"; import type { TruncationResult } from "../../core/tools/truncate"; @@ -44,11 +44,11 @@ import { BorderedLoader } from "./components/bordered-loader"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message"; import { CustomEditor } from "./components/custom-editor"; +import { CustomMessageComponent } from "./components/custom-message"; import { DynamicBorder } from "./components/dynamic-border"; import { ExtensionDashboard } from "./components/extensions"; import { HookEditorComponent } from "./components/hook-editor"; import { HookInputComponent } from "./components/hook-input"; -import { HookMessageComponent } from "./components/hook-message"; import { HookSelectorComponent } from "./components/hook-selector"; import { ModelSelectorComponent } from "./components/model-selector"; import { OAuthSelectorComponent } from "./components/oauth-selector"; @@ -163,9 +163,6 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; - // Custom tools for custom rendering - private customTools: Map; - // Convenience accessors private get agent() { return this.session.agent; @@ -181,8 +178,7 @@ export class InteractiveMode { session: AgentSession, version: string, changelogMarkdown: string | undefined = undefined, - customTools: LoadedCustomTool[] = [], - private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {}, + private setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {}, private lspServers: | Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined = undefined, @@ -191,7 +187,6 @@ export class InteractiveMode { this.session = session; this.version = version; this.changelogMarkdown = changelogMarkdown; - this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct])); this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); @@ -250,14 +245,15 @@ export class InteractiveMode { // Load hide thinking block setting this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); - // Convert file commands to SlashCommand format - const fileSlashCommands: SlashCommand[] = this.session.fileCommands.map((cmd) => ({ + // Load and convert file commands to SlashCommand format + const fileCommands = loadSlashCommands({ cwd: process.cwd() }); + const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({ name: cmd.name, description: cmd.description, })); // Convert hook commands to SlashCommand format - const hookCommands: SlashCommand[] = (this.session.hookRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ + const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({ name: cmd.name, description: cmd.description ?? "(hook command)", })); @@ -347,6 +343,10 @@ export class InteractiveMode { this.ui.start(); this.isInitialized = true; + // Set terminal title + const cwdBasename = path.basename(process.cwd()); + this.ui.terminal.setTitle(`pi - ${cwdBasename}`); + // Initialize hooks with TUI-based UI context await this.initHooksAndCustomTools(); @@ -379,12 +379,14 @@ export class InteractiveMode { */ private async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context - const uiContext: HookUIContext = { + const uiContext: ExtensionUIContext = { select: (title, options) => this.showHookSelector(title, options), confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), + setWidget: (key, content) => this.setHookWidget(key, content), + setTitle: (title) => setTerminalTitle(title), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), @@ -395,46 +397,17 @@ export class InteractiveMode { }; this.setToolUIContext(uiContext, true); - // Notify custom tools of session start - await this.emitCustomToolSessionEvent({ - reason: "start", - previousSessionFile: undefined, - }); - - const hookRunner = this.session.hookRunner; - if (!hookRunner) { + const extensionRunner = this.session.extensionRunner; + if (!extensionRunner) { return; // No hooks loaded } - this.initializeHookRunner(uiContext, true); - - // Subscribe to hook errors - hookRunner.onError((error) => { - if (this.isBackgrounded) { - console.error(`Hook "${error.hookPath}" error: ${error.error}`); - return; - } - this.showHookError(error.hookPath, error.error); - }); - - // Emit session_start event - await hookRunner.emit({ - type: "session_start", - }); - } - - private initializeHookRunner(uiContext: HookUIContext, hasUI: boolean): void { - const hookRunner = this.session.hookRunner; - if (!hookRunner) { - return; - } - - hookRunner.initialize({ + extensionRunner.initialize({ getModel: () => this.session.model, - sendMessageHandler: (message, triggerTurn) => { + sendMessageHandler: (message, options) => { const wasStreaming = this.session.isStreaming; this.session - .sendHookMessage(message, triggerTurn) + .sendCustomMessage(message, options) .then(() => { // For non-streaming cases with display=true, update UI // (streaming cases update via message_end event) @@ -443,7 +416,129 @@ export class InteractiveMode { } }) .catch((err) => { - const errorText = `Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; + this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, + appendEntryHandler: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + getActiveToolsHandler: () => this.session.getActiveToolNames(), + getAllToolsHandler: () => this.session.getAllToolNames(), + setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames), + newSessionHandler: async (options) => { + // Stop any loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + + // Create new session + const success = await this.session.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } + + // Call setup callback if provided + if (options?.setup) { + await options.setup(this.sessionManager); + } + + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), + ); + this.ui.requestRender(); + + return { cancelled: false }; + }, + branchHandler: async (entryId) => { + const result = await this.session.branch(entryId); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + this.editor.setText(result.selectedText); + this.showStatus("Branched to new session"); + + return { cancelled: false }; + }, + navigateTreeHandler: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + + return { cancelled: false }; + }, + isIdle: () => !this.session.isStreaming, + waitForIdle: () => this.session.agent.waitForIdle(), + abort: () => { + this.session.abort(); + }, + hasPendingMessages: () => this.session.queuedMessageCount > 0, + uiContext, + hasUI: true, + }); + + // Subscribe to extension errors + extensionRunner.onError((error) => { + this.showExtensionError(error.extensionPath, error.error); + }); + + // Emit session_start event + await extensionRunner.emit({ + type: "session_start", + }); + } + + /** + * Set extension widget content. + */ + private setHookWidget(key: string, content: unknown): void { + this.statusLine.setHookStatus(key, String(content)); + this.ui.requestRender(); + } + + private initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void { + const extensionRunner = this.session.extensionRunner; + if (!extensionRunner) { + return; + } + + extensionRunner.initialize({ + getModel: () => this.session.model, + sendMessageHandler: (message, options) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendCustomMessage(message, options) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!this.isBackgrounded && !wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err: Error) => { + const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; if (this.isBackgrounded) { console.error(errorText); return; @@ -454,6 +549,9 @@ export class InteractiveMode { appendEntryHandler: (customType, data) => { this.sessionManager.appendCustomEntry(customType, data); }, + getActiveToolsHandler: () => this.session.getActiveToolNames(), + getAllToolsHandler: () => this.session.getAllToolNames(), + setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), newSessionHandler: async (options) => { if (this.isBackgrounded) { return { cancelled: true }; @@ -532,19 +630,21 @@ export class InteractiveMode { abort: () => { this.session.abort(); }, - hasQueuedMessages: () => this.session.queuedMessageCount > 0, + hasPendingMessages: () => this.session.queuedMessageCount > 0, uiContext, hasUI, }); } - private createBackgroundUiContext(): HookUIContext { + private createBackgroundUiContext(): ExtensionUIContext { return { select: async (_title: string, _options: string[]) => undefined, confirm: async (_title: string, _message: string) => false, input: async (_title: string, _placeholder?: string) => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, + setTitle: () => {}, custom: async ( _factory: ( tui: TUI, @@ -562,24 +662,36 @@ export class InteractiveMode { } /** - * Emit session event to all custom tools. + * Emit session event to all extension tools. */ - private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise { - for (const { tool } of this.customTools.values()) { - if (tool.onSession) { + private async emitCustomToolSessionEvent( + reason: "start" | "switch" | "branch" | "tree" | "shutdown", + previousSessionFile?: string, + ): Promise { + const event = { reason, previousSessionFile }; + const uiContext = this.session.extensionRunner?.getUIContext(); + if (!uiContext) { + return; + } + for (const registeredTool of this.session.extensionRunner?.getAllRegisteredTools() ?? []) { + if (registeredTool.definition.onSession) { try { - await tool.onSession(event, { + await registeredTool.definition.onSession(event, { + ui: uiContext, + hasUI: !this.isBackgrounded, + cwd: this.sessionManager.getCwd(), sessionManager: this.session.sessionManager, modelRegistry: this.session.modelRegistry, model: this.session.model, isIdle: () => !this.session.isStreaming, + hasPendingMessages: () => this.session.queuedMessageCount > 0, hasQueuedMessages: () => this.session.queuedMessageCount > 0, abort: () => { this.session.abort(); }, }); } catch (err) { - this.showToolError(tool.name, err instanceof Error ? err.message : String(err)); + this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err)); } } } @@ -775,14 +887,10 @@ export class InteractiveMode { } /** - * Show a hook error in the UI. + * Show an extension error in the UI. */ - private showHookError(hookPath: string, error: string): void { - if (this.isBackgrounded) { - console.error(`Hook "${hookPath}" error: ${error}`); - return; - } - const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0); + private showExtensionError(extensionPath: string, error: string): void { + const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0); this.chatContainer.addChild(errorText); this.ui.requestRender(); } @@ -800,7 +908,7 @@ export class InteractiveMode { if (this.loadingAnimation) { // Abort and restore queued messages to editor const queuedMessages = this.session.clearQueue(); - const queuedText = queuedMessages.join("\n\n"); + const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n"); const currentText = this.editor.getText(); const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); this.editor.setText(combinedText); @@ -813,10 +921,14 @@ export class InteractiveMode { this.isBashMode = false; this.updateEditorBorderColor(); } else if (!this.editor.getText().trim()) { - // Double-escape with empty editor triggers /branch + // Double-escape with empty editor triggers /tree or /branch based on setting const now = Date.now(); if (now - this.lastEscapeTime < 500) { - this.showUserMessageSelector(); + if (this.settingsManager.getDoubleEscapeAction() === "tree") { + this.showTreeSelector(); + } else { + this.showUserMessageSelector(); + } this.lastEscapeTime = 0; } else { this.lastEscapeTime = now; @@ -830,6 +942,7 @@ export class InteractiveMode { this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onCtrlP = () => this.cycleModel("forward"); this.editor.onShiftCtrlP = () => this.cycleModel("backward"); + this.editor.onCtrlY = () => this.cycleRoleModel(); // Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); @@ -843,6 +956,9 @@ export class InteractiveMode { this.editor.onQuestionMark = () => this.handleHotkeysCommand(); this.editor.onCtrlV = () => this.handleImagePaste(); + // Wire up extension shortcuts + this.registerExtensionShortcuts(); + this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; this.isBashMode = text.trimStart().startsWith("!"); @@ -850,6 +966,25 @@ export class InteractiveMode { this.updateEditorBorderColor(); } }; + + this.editor.onAltEnter = async (text: string) => { + text = text.trim(); + if (!text) return; + + // Alt+Enter queues a follow-up message (waits until agent finishes) + // This handles extension commands (execute immediately), prompt template expansion, and queueing + if (this.session.isStreaming) { + this.editor.addToHistory(text); + this.editor.setText(""); + await this.session.prompt(text, { streamingBehavior: "followUp" }); + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + } + // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) + else if (this.editor.onSubmit) { + this.editor.onSubmit(text); + } + }; } private setupEditorSubmitHandler(): void { @@ -904,7 +1039,11 @@ export class InteractiveMode { return; } if (text === "/branch") { - this.showUserMessageSelector(); + if (this.settingsManager.getDoubleEscapeAction() === "tree") { + this.showTreeSelector(); + } else { + this.showUserMessageSelector(); + } this.editor.setText(""); return; } @@ -965,9 +1104,10 @@ export class InteractiveMode { return; } - // Handle bash command + // Handle bash command (! for normal, !! for excluded from context) if (text.startsWith("!")) { - const command = text.slice(1).trim(); + const isExcluded = text.startsWith("!!"); + const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); if (command) { if (this.session.isBashRunning) { this.showWarning("A bash command is already running. Press Esc to cancel it first."); @@ -975,7 +1115,7 @@ export class InteractiveMode { return; } this.editor.addToHistory(text); - await this.handleBashCommand(command); + await this.handleBashCommand(command, isExcluded); this.isBashMode = false; this.updateEditorBorderColor(); return; @@ -987,39 +1127,13 @@ export class InteractiveMode { return; } - // Hook commands always run immediately, even during streaming - // (if they need to interact with LLM, they use pi.sendMessage which handles queueing) - if (text.startsWith("/") && this.session.hookRunner) { - const spaceIndex = text.indexOf(" "); - const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const command = this.session.hookRunner.getCommand(commandName); - if (command) { - this.editor.addToHistory(text); - this.editor.setText(""); - await this.session.prompt(text); - return; - } - } - - // Custom commands (TypeScript slash commands) - route through session.prompt() - if (text.startsWith("/") && this.session.customCommands.length > 0) { - const spaceIndex = text.indexOf(" "); - const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const hasCustomCommand = this.session.customCommands.some((c) => c.command.name === commandName); - if (hasCustomCommand) { - this.editor.addToHistory(text); - this.editor.setText(""); - await this.session.prompt(text); - return; - } - } - - // Queue regular messages if agent is streaming + // If streaming, use prompt() with steer behavior + // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { - await this.session.queueMessage(text); - this.updatePendingMessagesDisplay(); this.editor.addToHistory(text); this.editor.setText(""); + await this.session.prompt(text, { streamingBehavior: "steer" }); + this.updatePendingMessagesDisplay(); this.ui.requestRender(); return; } @@ -1086,7 +1200,7 @@ export class InteractiveMode { break; case "message_start": - if (event.message.role === "hookMessage") { + if (event.message.role === "hookMessage" || event.message.role === "custom") { this.addMessageToChat(event.message); this.ui.requestRender(); } else if (event.message.role === "user") { @@ -1112,14 +1226,16 @@ export class InteractiveMode { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); + const tool = this.session.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, content.arguments, { showImages: this.settingsManager.getShowImages(), }, - this.customTools.get(content.name)?.tool, + tool, this.ui, + this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); @@ -1180,14 +1296,16 @@ export class InteractiveMode { case "tool_execution_start": { if (!this.pendingTools.has(event.toolCallId)) { + const tool = this.session.getToolByName(event.toolName); const component = new ToolExecutionComponent( event.toolName, event.args, { showImages: this.settingsManager.getShowImages(), }, - this.customTools.get(event.toolName)?.tool, + tool, this.ui, + this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); @@ -1402,7 +1520,7 @@ export class InteractiveMode { private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { - const component = new BashExecutionComponent(message.command, this.ui); + const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } @@ -1415,10 +1533,12 @@ export class InteractiveMode { this.chatContainer.addChild(component); break; } - case "hookMessage": { + case "hookMessage": + case "custom": { if (message.display) { - const renderer = this.session.hookRunner?.getMessageRenderer(message.customType); - this.chatContainer.addChild(new HookMessageComponent(message, renderer)); + const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType); + // Both HookMessage and CustomMessage have the same structure, cast for compatibility + this.chatContainer.addChild(new CustomMessageComponent(message as CustomMessage, renderer)); } break; } @@ -1494,12 +1614,14 @@ export class InteractiveMode { // Render tool call components for (const content of message.content) { if (content.type === "toolCall") { + const tool = this.session.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, content.arguments, { showImages: this.settingsManager.getShowImages() }, - this.customTools.get(content.name)?.tool, + tool, this.ui, + this.sessionManager.getCwd(), ); component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); @@ -1593,14 +1715,6 @@ export class InteractiveMode { await this.sessionManager.flush(); // Emit shutdown event to hooks - const hookRunner = this.session.hookRunner; - if (hookRunner?.hasHandlers("session_shutdown")) { - await hookRunner.emit({ - type: "session_shutdown", - }); - } - - // Emit shutdown event to custom tools await this.session.emitCustomToolSessionEvent("shutdown"); this.stop(); @@ -1818,7 +1932,7 @@ export class InteractiveMode { if (this.session.isStreaming) { await this.session.abort(); - await this.session.queueMessage(toSend); + await this.session.steer(toSend); this.updatePendingMessagesDisplay(); return; } @@ -1894,6 +2008,25 @@ export class InteractiveMode { } } + private async cycleRoleModel(): Promise { + try { + const result = await this.session.cycleRoleModels(["slow", "default", "smol"]); + if (!result) { + this.showStatus("Only one role model available"); + return; + } + + this.statusLine.invalidate(); + this.updateEditorBorderColor(); + const roleLabel = result.role === "default" ? "default" : result.role; + const thinkingStr = + result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; + this.showStatus(`Switched to ${roleLabel}: ${result.model.name || result.model.id}${thinkingStr}`); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + private toggleToolOutputExpansion(): void { this.toolOutputExpanded = !this.toolOutputExpanded; for (const child of this.chatContainer.children) { @@ -2023,10 +2156,13 @@ export class InteractiveMode { private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const queuedMessages = this.session.getQueuedMessages(); - if (queuedMessages.length > 0) { + const steeringMessages = queuedMessages.steering.map((message) => ({ message, label: "Steer" })); + const followUpMessages = queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })); + const allMessages = [...steeringMessages, ...followUpMessages]; + if (allMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); - for (const message of queuedMessages) { - const queuedText = theme.fg("dim", `Queued: ${message}`); + for (const entry of allMessages) { + const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } } @@ -2147,8 +2283,11 @@ export class InteractiveMode { this.session.setAutoCompactionEnabled(value as boolean); this.statusLine.setAutoCompactEnabled(value as boolean); break; - case "queueMode": - this.session.setQueueMode(value as "all" | "one-at-a-time"); + case "steeringMode": + this.session.setSteeringMode(value as "all" | "one-at-a-time"); + break; + case "followUpMode": + this.session.setFollowUpMode(value as "all" | "one-at-a-time"); break; case "interruptMode": this.session.setInterruptMode(value as "immediate" | "wait"); @@ -2509,7 +2648,7 @@ export class InteractiveMode { }, }); // Refresh models to pick up new baseUrl (e.g., github-copilot) - this.session.modelRegistry.refresh(); + await this.session.modelRegistry.refresh(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( @@ -2527,9 +2666,9 @@ export class InteractiveMode { } } else { try { - this.session.modelRegistry.authStorage.logout(providerId); + await this.session.modelRegistry.authStorage.logout(providerId); // Refresh models to reset baseUrl - this.session.modelRegistry.refresh(); + await this.session.modelRegistry.refresh(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild( new Text( @@ -2740,7 +2879,7 @@ export class InteractiveMode { const stats = this.session.getSessionStats(); let info = `${theme.bold("Session Info")}\n\n`; - info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`; + info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; info += `${theme.bold("Messages")}\n`; info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; @@ -2790,6 +2929,31 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Register extension-defined keyboard shortcuts with the editor. + */ + private registerExtensionShortcuts(): void { + const runner = this.session.extensionRunner; + if (!runner) return; + + const shortcuts = runner.getShortcuts(); + for (const [keyId, shortcut] of shortcuts) { + this.editor.setCustomKeyHandler(keyId, () => { + const ctx = runner.createCommandContext(); + try { + shortcut.handler(ctx); + } catch (err) { + runner.emitError({ + extensionPath: shortcut.extensionPath, + event: "shortcut", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + }); + } + } + private handleHotkeysCommand(): void { const hotkeys = ` **Navigation** @@ -2819,9 +2983,9 @@ export class InteractiveMode { | \`Ctrl+Z\` | Suspend to background | | \`Shift+Tab\` | Cycle thinking level | | \`Ctrl+P\` | Cycle models | +| \`Ctrl+Y\` | Cycle role models (slow/default/smol) | | \`Ctrl+O\` | Toggle tool output expansion | | \`Ctrl+T\` | Toggle thinking block visibility | -| \`Ctrl+Y\` | Voice mode toggle (auto-send on silence) | | \`Ctrl+G\` | Edit message in external editor | | \`/\` | Slash commands | | \`!\` | Run bash command | @@ -2906,9 +3070,9 @@ export class InteractiveMode { this.ui.requestRender(); } - private async handleBashCommand(command: string): Promise { + private async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.session.isStreaming; - this.bashComponent = new BashExecutionComponent(command, this.ui); + this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); if (isDeferred) { // Show in pending area when agent is streaming @@ -2921,12 +3085,16 @@ export class InteractiveMode { this.ui.requestRender(); try { - const result = await this.session.executeBash(command, (chunk) => { - if (this.bashComponent) { - this.bashComponent.appendOutput(chunk); - this.ui.requestRender(); - } - }); + const result = await this.session.executeBash( + command, + (chunk) => { + if (this.bashComponent) { + this.bashComponent.appendOutput(chunk); + this.ui.requestRender(); + } + }, + { excludeFromContext }, + ); if (this.bashComponent) { this.bashComponent.setComplete( diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index a26cc474c..680e2b778 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -7,34 +7,7 @@ */ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; -import { APP_NAME, VERSION } from "../config"; import type { AgentSession } from "../core/agent-session"; -import { logger } from "../core/logger"; - -/** - * Print session header to stderr (text mode only). - */ -function printHeader(session: AgentSession): void { - const model = session.model; - const lines = [ - `${APP_NAME} v${VERSION}`, - "--------", - `workdir: ${process.cwd()}`, - `model: ${model?.id ?? "unknown"}`, - `provider: ${model?.provider ?? "unknown"}`, - `thinking: ${session.thinkingLevel}`, - `session: ${session.sessionId}`, - "--------", - ]; - console.error(lines.join("\n")); -} - -/** - * Print session footer to stderr (text mode only). - */ -function printFooter(): void { - console.error("--------"); -} /** * Run in print (single-shot) mode. @@ -53,61 +26,33 @@ export async function runPrintMode( initialMessage?: string, initialImages?: ImageContent[], ): Promise { - // Print header to stderr (text mode only) - if (mode === "text") { - printHeader(session); - } - - // Hook runner already has no-op UI context by default (set in main.ts) - // Set up hooks for print mode (no UI) - const hookRunner = session.hookRunner; - if (hookRunner) { - hookRunner.initialize({ + // Extension runner already has no-op UI context by default (set in loader) + // Set up extensions for print mode (no UI) + const extensionRunner = session.extensionRunner; + if (extensionRunner) { + extensionRunner.initialize({ getModel: () => session.model, - sendMessageHandler: (message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + sendMessageHandler: (message, options) => { + session.sendCustomMessage(message, options).catch((e) => { + console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); }); }, appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, + getActiveToolsHandler: () => session.getActiveToolNames(), + getAllToolsHandler: () => session.getAllToolNames(), + setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), }); - hookRunner.onError((err) => { - console.error(`Hook error (${err.hookPath}): ${err.error}`); + extensionRunner.onError((err) => { + console.error(`Extension error (${err.extensionPath}): ${err.error}`); }); // Emit session_start event - await hookRunner.emit({ + await extensionRunner.emit({ type: "session_start", }); } - // Emit session start event to custom tools (no UI in print mode) - for (const { tool } of session.customTools) { - if (tool.onSession) { - try { - await tool.onSession( - { - reason: "start", - previousSessionFile: undefined, - }, - { - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, - model: session.model, - isIdle: () => !session.isStreaming, - hasQueuedMessages: () => session.queuedMessageCount > 0, - abort: () => { - session.abort(); - }, - }, - ); - } catch (err) { - logger.warn("Tool onSession error", { error: String(err) }); - } - } - } - // Always subscribe to enable session persistence via _handleAgentEvent session.subscribe((event) => { // In JSON mode, output all events @@ -147,9 +92,6 @@ export async function runPrintMode( } } } - - // Print footer to stderr - printFooter(); } // Ensure stdout is fully flushed before returning diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 8a63ef80f..9bea1e8fb 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -210,10 +210,17 @@ export class RpcClient { } /** - * Queue a message while agent is streaming. + * Queue a steering message to interrupt the agent mid-run. */ - async queueMessage(message: string): Promise { - await this.send({ type: "queue_message", message }); + async steer(message: string): Promise { + await this.send({ type: "steer", message }); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + */ + async followUp(message: string): Promise { + await this.send({ type: "follow_up", message }); } /** @@ -226,7 +233,7 @@ export class RpcClient { /** * Start a new session, optionally with parent tracking. * @param parentSession - Optional parent session path for lineage tracking - * @returns Object with `cancelled: true` if a hook cancelled the new session + * @returns Object with `cancelled: true` if an extension cancelled the new session */ async newSession(parentSession?: string): Promise<{ cancelled: boolean }> { const response = await this.send({ type: "new_session", parentSession }); @@ -285,10 +292,17 @@ export class RpcClient { } /** - * Set queue mode. + * Set steering mode. */ - async setQueueMode(mode: "all" | "one-at-a-time"): Promise { - await this.send({ type: "set_queue_mode", mode }); + async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_steering_mode", mode }); + } + + /** + * Set follow-up mode. + */ + async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_follow_up_mode", mode }); } /** @@ -353,7 +367,7 @@ export class RpcClient { /** * Switch to a different session file. - * @returns Object with `cancelled: true` if a hook cancelled the switch + * @returns Object with `cancelled: true` if an extension cancelled the switch */ async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { const response = await this.send({ type: "switch_session", sessionPath }); @@ -362,7 +376,7 @@ export class RpcClient { /** * Branch from a specific message. - * @returns Object with `text` (the message text) and `cancelled` (if hook cancelled) + * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled) */ async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { const response = await this.send({ type: "branch", entryId }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 51ff21569..a8dd0b1ab 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -8,25 +8,35 @@ * - Commands: JSON objects with `type` field, optional `id` for correlation * - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error` * - Events: AgentSessionEvent objects streamed as they occur - * - Hook UI: Hook UI requests are emitted, client responds with hook_ui_response + * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response */ -import { nanoid } from "nanoid"; import type { AgentSession } from "../../core/agent-session"; -import type { HookUIContext } from "../../core/hooks/index"; -import { logger } from "../../core/logger"; +import type { ExtensionUIContext } from "../../core/extensions/index"; import { theme } from "../interactive/theme/theme"; -import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types"; +import type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc-types"; // Re-export types for consumers -export type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types"; +export type { + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "./rpc-types"; /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(session: AgentSession): Promise { - const output = (obj: RpcResponse | RpcHookUIRequest | object) => { + const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { console.log(JSON.stringify(obj)); }; @@ -45,18 +55,21 @@ export async function runRpcMode(session: AgentSession): Promise { return { id, type: "response", command, success: false, error: message }; }; - // Pending hook UI requests waiting for response - const pendingHookRequests = new Map void; reject: (error: Error) => void }>(); + // Pending extension UI requests waiting for response + const pendingExtensionRequests = new Map< + string, + { resolve: (value: any) => void; reject: (error: Error) => void } + >(); /** - * Create a hook UI context that uses the RPC protocol. + * Create an extension UI context that uses the RPC protocol. */ - const createHookUIContext = (): HookUIContext => ({ + const createExtensionUIContext = (): ExtensionUIContext => ({ async select(title: string, options: string[]): Promise { - const id = nanoid(); + const id = globalThis.crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -67,15 +80,15 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "select", title, options } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest); }); }, async confirm(title: string, message: string): Promise { - const id = nanoid(); + const id = globalThis.crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(false); } else if ("confirmed" in response) { @@ -86,15 +99,15 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "confirm", title, message } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest); }); }, async input(title: string, placeholder?: string): Promise { - const id = nanoid(); + const id = globalThis.crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -105,30 +118,54 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "input", title, placeholder } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest); }); }, notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed output({ - type: "hook_ui_request", - id: nanoid(), + type: "extension_ui_request", + id: globalThis.crypto.randomUUID(), method: "notify", message, notifyType: type, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, setStatus(key: string, text: string | undefined): void { // Fire and forget - no response needed output({ - type: "hook_ui_request", - id: nanoid(), + type: "extension_ui_request", + id: globalThis.crypto.randomUUID(), method: "setStatus", statusKey: key, statusText: text, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); + }, + + setWidget(key: string, content: unknown): void { + // Only support string arrays in RPC mode - factory functions are ignored + if (content === undefined || Array.isArray(content)) { + output({ + type: "extension_ui_request", + id: globalThis.crypto.randomUUID(), + method: "setWidget", + widgetKey: key, + widgetLines: content as string[] | undefined, + } as RpcExtensionUIRequest); + } + // Component factories are not supported in RPC mode - would need TUI access + }, + + setTitle(title: string): void { + // Fire and forget - host can implement terminal title control + output({ + type: "extension_ui_request", + id: globalThis.crypto.randomUUID(), + method: "setTitle", + title, + } as RpcExtensionUIRequest); }, async custom() { @@ -139,11 +176,11 @@ export async function runRpcMode(session: AgentSession): Promise { setEditorText(text: string): void { // Fire and forget - host can implement editor control output({ - type: "hook_ui_request", - id: nanoid(), + type: "extension_ui_request", + id: globalThis.crypto.randomUUID(), method: "set_editor_text", text, - } as RpcHookUIRequest); + } as RpcExtensionUIRequest); }, getEditorText(): string { @@ -153,10 +190,10 @@ export async function runRpcMode(session: AgentSession): Promise { }, async editor(title: string, prefill?: string): Promise { - const id = nanoid(); + const id = globalThis.crypto.randomUUID(); return new Promise((resolve, reject) => { - pendingHookRequests.set(id, { - resolve: (response: RpcHookUIResponse) => { + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -167,7 +204,7 @@ export async function runRpcMode(session: AgentSession): Promise { }, reject, }); - output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest); + output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest); }); }, @@ -176,66 +213,39 @@ export async function runRpcMode(session: AgentSession): Promise { }, }); - // Set up hooks with RPC-based UI context - const hookRunner = session.hookRunner; - if (hookRunner) { - hookRunner.initialize({ + // Set up extensions with RPC-based UI context + const extensionRunner = session.extensionRunner; + if (extensionRunner) { + extensionRunner.initialize({ getModel: () => session.agent.state.model, - sendMessageHandler: (message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - output(error(undefined, "hook_send", e.message)); + sendMessageHandler: (message, options) => { + session.sendCustomMessage(message, options).catch((e) => { + output(error(undefined, "extension_send", e.message)); }); }, appendEntryHandler: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); }, - uiContext: createHookUIContext(), + getActiveToolsHandler: () => session.getActiveToolNames(), + getAllToolsHandler: () => session.getAllToolNames(), + setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + uiContext: createExtensionUIContext(), hasUI: false, }); - hookRunner.onError((err) => { - output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); + extensionRunner.onError((err) => { + output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }); // Emit session_start event - await hookRunner.emit({ + await extensionRunner.emit({ type: "session_start", }); } - // Emit session start event to custom tools - // Note: Tools get no-op UI context in RPC mode (host handles UI via protocol) - for (const { tool } of session.customTools) { - if (tool.onSession) { - try { - await tool.onSession( - { - previousSessionFile: undefined, - reason: "start", - }, - { - sessionManager: session.sessionManager, - modelRegistry: session.modelRegistry, - model: session.model, - isIdle: () => !session.isStreaming, - hasQueuedMessages: () => session.queuedMessageCount > 0, - abort: () => { - session.abort(); - }, - }, - ); - } catch (err) { - logger.warn("Tool onSession error", { error: String(err) }); - } - } - } - // Output all agent events as JSON session.subscribe((event) => { output(event); }); - // Serialize prompt commands to prevent concurrent execution - let activePrompt: Promise | null = null; - // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { const id = command.id; @@ -246,24 +256,26 @@ export async function runRpcMode(session: AgentSession): Promise { // ================================================================= case "prompt": { - // Serialize prompts to prevent concurrent execution - if (activePrompt) { - await activePrompt; - } - activePrompt = session + // Don't await - events will stream + // Extension commands are executed immediately, file prompt templates are expanded + // If streaming and streamingBehavior specified, queues via steer/followUp + session .prompt(command.message, { images: command.images, + streamingBehavior: command.streamingBehavior, }) - .catch((e) => output(error(id, "prompt", e.message))) - .finally(() => { - activePrompt = null; - }); + .catch((e) => output(error(id, "prompt", e.message))); return success(id, "prompt"); } - case "queue_message": { - await session.queueMessage(command.message); - return success(id, "queue_message"); + case "steer": { + await session.steer(command.message); + return success(id, "steer"); + } + + case "follow_up": { + await session.followUp(command.message); + return success(id, "follow_up"); } case "abort": { @@ -287,7 +299,9 @@ export async function runRpcMode(session: AgentSession): Promise { thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, - queueMode: session.queueMode, + steeringMode: session.steeringMode, + followUpMode: session.followUpMode, + interruptMode: session.interruptMode, sessionFile: session.sessionFile, sessionId: session.sessionId, autoCompactionEnabled: session.autoCompactionEnabled, @@ -302,7 +316,7 @@ export async function runRpcMode(session: AgentSession): Promise { // ================================================================= case "set_model": { - const models = await session.getAvailableModels(); + const models = session.getAvailableModels(); const model = models.find((m) => m.provider === command.provider && m.id === command.modelId); if (!model) { return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`); @@ -320,7 +334,7 @@ export async function runRpcMode(session: AgentSession): Promise { } case "get_available_models": { - const models = await session.getAvailableModels(); + const models = session.getAvailableModels(); return success(id, "get_available_models", { models }); } @@ -342,12 +356,22 @@ export async function runRpcMode(session: AgentSession): Promise { } // ================================================================= - // Queue Mode + // Queue Modes // ================================================================= - case "set_queue_mode": { - session.setQueueMode(command.mode); - return success(id, "set_queue_mode"); + case "set_steering_mode": { + session.setSteeringMode(command.mode); + return success(id, "set_steering_mode"); + } + + case "set_follow_up_mode": { + session.setFollowUpMode(command.mode); + return success(id, "set_follow_up_mode"); + } + + case "set_interrupt_mode": { + session.setInterruptMode(command.mode); + return success(id, "set_interrupt_mode"); } // ================================================================= @@ -441,42 +465,30 @@ export async function runRpcMode(session: AgentSession): Promise { } }; - // Listen for JSON input - use Bun's ReadableStream - const stdinReader = (Bun.stdin.stream() as ReadableStream) - .pipeThrough(new TextDecoderStream()) - .pipeThrough( - new TransformStream({ - transform(chunk, controller) { - const lines = chunk.split("\n"); - for (const line of lines) { - if (line.trim()) { - controller.enqueue(line); - } - } - }, - }), - ) - .getReader(); + // Listen for JSON input using Bun's stdin + const decoder = new TextDecoder(); + let buffer = ""; - // Process lines in background - (async () => { - while (true) { - const { done, value: line } = await stdinReader.read(); - if (done) break; + for await (const chunk of Bun.stdin.stream()) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; try { const parsed = JSON.parse(line); - // Handle hook UI responses - if (parsed.type === "hook_ui_response") { - const response = parsed as RpcHookUIResponse; - const pending = pendingHookRequests.get(response.id); + // Handle extension UI responses + if (parsed.type === "extension_ui_response") { + const response = parsed as RpcExtensionUIResponse; + const pending = pendingExtensionRequests.get(response.id); if (pending) { - // Atomic delete: remove before resolve to prevent double-resolution - pendingHookRequests.delete(response.id); + pendingExtensionRequests.delete(response.id); pending.resolve(response); } - return; + continue; } // Handle regular commands @@ -487,7 +499,7 @@ export async function runRpcMode(session: AgentSession): Promise { output(error(undefined, "parse", `Failed to parse command: ${e.message}`)); } } - })(); + } // Keep process alive forever return new Promise(() => {}); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 6e822ab41..40f869995 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -17,8 +17,9 @@ import type { CompactionResult } from "../../core/compaction/index"; export type RpcCommand = // Prompting - | { id?: string; type: "prompt"; message: string; images?: ImageContent[] } - | { id?: string; type: "queue_message"; message: string } + | { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" } + | { id?: string; type: "steer"; message: string } + | { id?: string; type: "follow_up"; message: string } | { id?: string; type: "abort" } | { id?: string; type: "new_session"; parentSession?: string } @@ -34,8 +35,10 @@ export type RpcCommand = | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } | { id?: string; type: "cycle_thinking_level" } - // Queue mode - | { id?: string; type: "set_queue_mode"; mode: "all" | "one-at-a-time" } + // Queue modes + | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } + | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } + | { id?: string; type: "set_interrupt_mode"; mode: "immediate" | "wait" } // Compaction | { id?: string; type: "compact"; customInstructions?: string } @@ -69,7 +72,9 @@ export interface RpcSessionState { thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; - queueMode: "all" | "one-at-a-time"; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; + interruptMode: "immediate" | "wait"; sessionFile?: string; sessionId: string; autoCompactionEnabled: boolean; @@ -85,7 +90,8 @@ export interface RpcSessionState { export type RpcResponse = // Prompting (async - events follow) | { id?: string; type: "response"; command: "prompt"; success: true } - | { id?: string; type: "response"; command: "queue_message"; success: true } + | { id?: string; type: "response"; command: "steer"; success: true } + | { id?: string; type: "response"; command: "follow_up"; success: true } | { id?: string; type: "response"; command: "abort"; success: true } | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } } @@ -125,8 +131,10 @@ export type RpcResponse = data: { level: ThinkingLevel } | null; } - // Queue mode - | { id?: string; type: "response"; command: "set_queue_mode"; success: true } + // Queue modes + | { id?: string; type: "response"; command: "set_steering_mode"; success: true } + | { id?: string; type: "response"; command: "set_follow_up_mode"; success: true } + | { id?: string; type: "response"; command: "set_interrupt_mode"; success: true } // Compaction | { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult } @@ -167,34 +175,48 @@ export type RpcResponse = | { id?: string; type: "response"; command: string; success: false; error: string }; // ============================================================================ -// Hook UI Events (stdout) +// Extension UI Events (stdout) // ============================================================================ -/** Emitted when a hook needs user input */ -export type RpcHookUIRequest = - | { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] } - | { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string } - | { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string } - | { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string } +/** Emitted when an extension needs user input */ +export type RpcExtensionUIRequest = + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string } + | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { - type: "hook_ui_request"; + type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error"; } - | { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } - | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; + | { + type: "extension_ui_request"; + id: string; + method: "setStatus"; + statusKey: string; + statusText: string | undefined; + } + | { + type: "extension_ui_request"; + id: string; + method: "setWidget"; + widgetKey: string; + widgetLines: string[] | undefined; + } + | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } + | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ -// Hook UI Commands (stdin) +// Extension UI Commands (stdin) // ============================================================================ -/** Response to a hook UI request */ -export type RpcHookUIResponse = - | { type: "hook_ui_response"; id: string; value: string } - | { type: "hook_ui_response"; id: string; confirmed: boolean } - | { type: "hook_ui_response"; id: string; cancelled: true }; +/** Response to an extension UI request */ +export type RpcExtensionUIResponse = + | { type: "extension_ui_response"; id: string; value: string } + | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; cancelled: true }; // ============================================================================ // Helper type for extracting command types diff --git a/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts new file mode 100644 index 000000000..89a651ce5 --- /dev/null +++ b/packages/coding-agent/src/utils/image-convert.ts @@ -0,0 +1,26 @@ +/** + * Convert image to PNG format for terminal display. + * Kitty graphics protocol requires PNG format (f=100). + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + // Already PNG, no conversion needed + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + try { + const sharp = (await import("sharp")).default; + const buffer = Buffer.from(base64Data, "base64"); + const pngBuffer = await sharp(buffer).png().toBuffer(); + return { + data: pngBuffer.toString("base64"), + mimeType: "image/png", + }; + } catch { + // Sharp not available or conversion failed + return null; + } +} diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts new file mode 100644 index 000000000..d5c1b4e95 --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -0,0 +1,215 @@ +import type { ImageContent } from "@oh-my-pi/pi-ai"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB - provides headroom below Anthropic's 5MB limit +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +/** Helper to pick the smaller of two buffers */ +function pickSmaller( + a: { buffer: Buffer; mimeType: string }, + b: { buffer: Buffer; mimeType: string }, +): { buffer: Buffer; mimeType: string } { + return a.buffer.length <= b.buffer.length ? a : b; +} + +/** + * Resize an image to fit within the specified max dimensions and file size. + * Returns the original image if it already fits within the limits. + * + * Uses sharp for image processing. If sharp is not available (e.g., in some + * environments), returns the original image unchanged. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions + */ +export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const buffer = Buffer.from(img.data, "base64"); + + let sharp: typeof import("sharp") | undefined; + try { + sharp = (await import("sharp")).default; + } catch { + // Sharp not available - return original image + // We can't get dimensions without sharp, so return 0s + return { + data: img.data, + mimeType: img.mimeType, + originalWidth: 0, + originalHeight: 0, + width: 0, + height: 0, + wasResized: false, + }; + } + + const sharpImg = sharp(buffer); + const metadata = await sharpImg.metadata(); + + const originalWidth = metadata.width ?? 0; + const originalHeight = metadata.height ?? 0; + const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND size) + const originalSize = buffer.length; + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) { + return { + data: img.data, + mimeType: img.mimeType ?? `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + // Helper to resize and encode in both formats, returning the smaller one + async function tryBothFormats( + width: number, + height: number, + jpegQuality: number, + ): Promise<{ buffer: Buffer; mimeType: string }> { + const resized = await sharp!(buffer) + .resize(width, height, { fit: "inside", withoutEnlargement: true }) + .toBuffer(); + + const [pngBuffer, jpegBuffer] = await Promise.all([ + sharp!(resized).png({ compressionLevel: 9 }).toBuffer(), + sharp!(resized).jpeg({ quality: jpegQuality }).toBuffer(), + ]); + + return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" }); + } + + // Try to produce an image under maxBytes + const qualitySteps = [85, 70, 55, 40]; + const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25]; + + let best: { buffer: Buffer; mimeType: string }; + let finalWidth = targetWidth; + let finalHeight = targetHeight; + + // First attempt: resize to target dimensions, try both formats + best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality); + + if (best.buffer.length <= opts.maxBytes) { + return { + data: best.buffer.toString("base64"), + mimeType: best.mimeType, + originalWidth, + originalHeight, + width: finalWidth, + height: finalHeight, + wasResized: true, + }; + } + + // Still too large - try JPEG with decreasing quality (and compare to PNG each time) + for (const quality of qualitySteps) { + best = await tryBothFormats(targetWidth, targetHeight, quality); + + if (best.buffer.length <= opts.maxBytes) { + return { + data: best.buffer.toString("base64"), + mimeType: best.mimeType, + originalWidth, + originalHeight, + width: finalWidth, + height: finalHeight, + wasResized: true, + }; + } + } + + // Still too large - reduce dimensions progressively + for (const scale of scaleSteps) { + finalWidth = Math.round(targetWidth * scale); + finalHeight = Math.round(targetHeight * scale); + + // Skip if dimensions are too small + if (finalWidth < 100 || finalHeight < 100) { + break; + } + + for (const quality of qualitySteps) { + best = await tryBothFormats(finalWidth, finalHeight, quality); + + if (best.buffer.length <= opts.maxBytes) { + return { + data: best.buffer.toString("base64"), + mimeType: best.mimeType, + originalWidth, + originalHeight, + width: finalWidth, + height: finalHeight, + wasResized: true, + }; + } + } + } + + // Last resort: return smallest version we produced even if over limit + // (the API will reject it, but at least we tried everything) + return { + data: best.buffer.toString("base64"), + mimeType: best.mimeType, + originalWidth, + originalHeight, + width: finalWidth, + height: finalHeight, + wasResized: true, + }; +} + +/** + * Format a dimension note for resized images. + * This helps the model understand the coordinate mapping. + */ +export function formatDimensionNote(result: ResizedImage): string | undefined { + if (!result.wasResized) { + return undefined; + } + + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; +} diff --git a/packages/coding-agent/src/utils/shell-snapshot.ts b/packages/coding-agent/src/utils/shell-snapshot.ts index 0e7447d80..0a303b154 100644 --- a/packages/coding-agent/src/utils/shell-snapshot.ts +++ b/packages/coding-agent/src/utils/shell-snapshot.ts @@ -6,7 +6,6 @@ * shell experience. */ -import { existsSync, mkdirSync, unlinkSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -28,9 +27,11 @@ function getShellConfigFile(shell: string): string { * This script sources the user's rc file and extracts functions, aliases, and options. * Matches Claude Code's snapshot generation logic. */ -function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): string { - const hasRcFile = existsSync(rcFile); +async function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): Promise { + const hasRcFile = await Bun.file(rcFile).exists(); const isZsh = shell.includes("zsh"); + const commonToolsRegex = + "^(ls|dir|vdir|cat|head|tail|less|more|grep|egrep|fgrep|rg|find|fd|locate|sed|awk|perl|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|pwd|readlink|stat|cut|sort|uniq|xargs|tee|tr|basename|dirname)$"; // Escape the snapshot path for shell const escapedPath = snapshotPath.replace(/'/g, "'\\''"); @@ -42,7 +43,7 @@ echo "# Functions" >> "$SNAPSHOT_FILE" # Force autoload all functions first typeset -f > /dev/null 2>&1 # Get user function names - filter system/private ones -typeset +f 2>/dev/null | grep -vE '^(_|__)' | while read func; do +typeset +f 2>/dev/null | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do typeset -f "$func" >> "$SNAPSHOT_FILE" 2>/dev/null done ` @@ -51,7 +52,7 @@ echo "# Functions" >> "$SNAPSHOT_FILE" # Force autoload all functions first declare -f > /dev/null 2>&1 # Get user function names - filter system/private ones, use base64 for special chars -declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | while read func; do +declare -F 2>/dev/null | cut -d' ' -f3 | grep -vE '^(_|__)' | grep -vE '${commonToolsRegex}' | while read func; do encoded_func=$(declare -f "$func" | base64) echo "eval \\"\\$(echo '$encoded_func' | base64 -d)\\" > /dev/null 2>&1" >> "$SNAPSHOT_FILE" done @@ -90,9 +91,9 @@ ${optionsScript} echo "# Aliases" >> "$SNAPSHOT_FILE" # Filter out winpty aliases on Windows to avoid "stdin is not a tty" errors if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then - alias 2>/dev/null | grep -v "='winpty " | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" + alias 2>/dev/null | grep -v "='winpty " | grep -vE '^alias (${commonToolsRegex})=' | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" else - alias 2>/dev/null | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" + alias 2>/dev/null | grep -vE '^alias (${commonToolsRegex})=' | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE" fi # Export PATH @@ -115,7 +116,7 @@ export async function getOrCreateSnapshot( env: Record, ): Promise { // Return cached snapshot if valid - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { + if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) { return cachedSnapshotPath; } @@ -128,9 +129,8 @@ export async function getOrCreateSnapshot( // Create snapshot directory const snapshotDir = join(tmpdir(), "omp-shell-snapshots"); - try { - mkdirSync(snapshotDir, { recursive: true }); - } catch { + const mkdirProc = Bun.spawnSync(["mkdir", "-p", snapshotDir]); + if (mkdirProc.exitCode !== 0) { return null; } @@ -141,7 +141,7 @@ export async function getOrCreateSnapshot( const snapshotPath = join(snapshotDir, `snapshot-${shellName}-${timestamp}-${random}.sh`); // Generate and execute snapshot script - const script = generateSnapshotScript(shell, snapshotPath, rcFile); + const script = await generateSnapshotScript(shell, snapshotPath, rcFile); try { const result = Bun.spawnSync([shell, "-l", "-c", script], { @@ -152,7 +152,7 @@ export async function getOrCreateSnapshot( timeout: 10000, // 10 second timeout }); - if (result.exitCode === 0 && existsSync(snapshotPath)) { + if (result.exitCode === 0 && (await Bun.file(snapshotPath).exists())) { cachedSnapshotPath = snapshotPath; registerCleanup(); return snapshotPath; @@ -182,17 +182,19 @@ function registerCleanup(): void { if (cleanupRegistered) return; cleanupRegistered = true; - const cleanup = () => { - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { + const cleanup = async () => { + if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) { try { - unlinkSync(cachedSnapshotPath); + Bun.spawnSync(["rm", cachedSnapshotPath]); } catch { // Ignore cleanup errors } } }; - process.on("exit", cleanup); + process.on("exit", () => { + cleanup(); + }); process.on("SIGINT", () => { cleanup(); process.exit(130); @@ -206,10 +208,10 @@ function registerCleanup(): void { /** * Clear the cached snapshot (for testing or forced refresh). */ -export function clearSnapshotCache(): void { - if (cachedSnapshotPath && existsSync(cachedSnapshotPath)) { +export async function clearSnapshotCache(): Promise { + if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) { try { - unlinkSync(cachedSnapshotPath); + Bun.spawnSync(["rm", cachedSnapshotPath]); } catch { // Ignore } diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 272d29a13..80c5e062b 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -7,12 +7,12 @@ * - getUserMessagesForBranching returns correct entries */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index f758d2f6d..cab174c73 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -7,12 +7,12 @@ * - Compaction entry is saved to session file */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { ModelRegistry } from "../src/core/model-registry"; diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts new file mode 100644 index 000000000..1f9b62e01 --- /dev/null +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for AgentSession concurrent prompt guard. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent } from "@oh-my-pi/pi-agent-core"; +import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@oh-my-pi/pi-ai"; +import { AgentSession } from "../src/core/agent-session"; +import { AuthStorage } from "../src/core/auth-storage"; +import { ModelRegistry } from "../src/core/model-registry"; +import { SessionManager } from "../src/core/session-manager"; +import { SettingsManager } from "../src/core/settings-manager"; + +// Mock stream that mimics AssistantMessageEventStream +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createAssistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +describe("AgentSession concurrent prompt guard", () => { + let session: AgentSession; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createSession() { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + let abortSignal: AbortSignal | undefined; + + // Use a stream function that responds to abort + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [], + }, + streamFn: (_model, _context, options) => { + abortSignal = options?.signal; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + const checkAbort = () => { + if (abortSignal?.aborted) { + stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); + } else { + setTimeout(checkAbort, 5); + } + }; + checkAbort(); + }); + return stream; + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + // Set a runtime API key so validation passes + authStorage.setRuntimeApiKey("anthropic", "test-key"); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + return session; + } + + it("should throw when prompt() called while streaming", async () => { + createSession(); + + // Start first prompt (don't await, it will block until abort) + const firstPrompt = session.prompt("First message"); + + // Wait a tick for isStreaming to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify we're streaming + expect(session.isStreaming).toBe(true); + + // Second prompt should reject + await expect(session.prompt("Second message")).rejects.toThrow( + "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + ); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); // Ignore abort error + }); + + it("should allow steer() while streaming", async () => { + createSession(); + + // Start first prompt + const firstPrompt = session.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // steer should work while streaming + expect(() => session.steer("Steering message")).not.toThrow(); + expect(session.queuedMessageCount).toBe(1); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); + }); + + it("should allow followUp() while streaming", async () => { + createSession(); + + // Start first prompt + const firstPrompt = session.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // followUp should work while streaming + expect(() => session.followUp("Follow-up message")).not.toThrow(); + expect(session.queuedMessageCount).toBe(1); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); + }); + + it("should allow prompt() after previous completes", async () => { + // Create session with a stream that completes immediately + const model = getModel("anthropic", "claude-sonnet-4-5")!; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [], + }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") }); + }); + return stream; + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + // First prompt completes + await session.prompt("First message"); + + // Should not be streaming anymore + expect(session.isStreaming).toBe(false); + + // Second prompt should work + await expect(session.prompt("Second message")).resolves.toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts index a6ba25f59..4817876a9 100644 --- a/packages/coding-agent/test/agent-session-tree-navigation.test.ts +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -9,7 +9,7 @@ * - Abort handling during summarization */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { API_KEY, createTestSession, type TestSessionContext } from "./utilities"; describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { @@ -102,7 +102,7 @@ describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { expect(result.summaryEntry?.parentId).toBeNull(); // Leaf should be the summary entry - expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id); + expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id ?? null); }, 120000); it("should attach summary to correct parent when navigating to nested user message", async () => { @@ -169,7 +169,7 @@ describe.skipIf(!API_KEY)("AgentSession tree navigation e2e", () => { expect(result.summaryEntry?.parentId).toBe(a1.id); // Leaf should be the summary entry - expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id); + expect(sessionManager.getLeafId()).toBe(result.summaryEntry?.id ?? null); }, 120000); it("should handle abort during summarization", async () => { diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index f81b8f8c7..62e437771 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test } from "bun:test"; import { parseArgs } from "../src/cli/args"; describe("parseArgs", () => { diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index 8a6d22051..373c833d9 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -2,7 +2,7 @@ * Verify the documentation example from hooks.md compiles and works. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import type { HookAPI, SessionBeforeCompactEvent, SessionCompactEvent } from "../src/core/hooks/index"; describe("Documentation example", () => { @@ -52,12 +52,12 @@ describe("Documentation example", () => { pi.on("session_compact", async (event: SessionCompactEvent) => { // These should all be accessible const entry = event.compactionEntry; - const fromHook = event.fromHook; + const fromExtension = event.fromExtension; expect(entry.type).toBe("compaction"); expect(typeof entry.summary).toBe("string"); expect(typeof entry.tokensBefore).toBe("number"); - expect(typeof fromHook).toBe("boolean"); + expect(typeof fromExtension).toBe("boolean"); }); }; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 08bd8bb03..d93b008db 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -2,12 +2,12 @@ * Tests for compaction hook events (before_compact / compact). */ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getModel } from "@oh-my-pi/pi-ai"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session"; import { AuthStorage } from "../src/core/auth-storage"; import { @@ -20,7 +20,7 @@ import { import { ModelRegistry } from "../src/core/model-registry"; import { SessionManager } from "../src/core/session-manager"; import { SettingsManager } from "../src/core/settings-manager"; -import { codingTools } from "../src/core/tools/index"; +import { createCodingTools } from "../src/core/tools/index"; import { theme } from "../src/modes/interactive/theme/theme"; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; @@ -83,14 +83,15 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { }; } - function createSession(hooks: LoadedHook[]) { + async function createSession(hooks: LoadedHook[]) { const model = getModel("anthropic", "claude-sonnet-4-5")!; + const tools = await createCodingTools(tempDir); const agent = new Agent({ getApiKey: () => API_KEY, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", - tools: codingTools, + tools, }, }); @@ -125,7 +126,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { agent, sessionManager, settingsManager, - hookRunner, + extensionRunner: hookRunner as any, modelRegistry, }); @@ -134,7 +135,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { it("should emit before_compact and compact events", async () => { const hook = createHook(); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -165,12 +166,12 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { expect(afterEvent.compactionEntry).toBeDefined(); expect(afterEvent.compactionEntry.summary.length).toBeGreaterThan(0); expect(afterEvent.compactionEntry.tokensBefore).toBeGreaterThanOrEqual(0); - expect(afterEvent.fromHook).toBe(false); + expect(afterEvent.fromExtension).toBe(false); }, 120000); it("should allow hooks to cancel compaction", async () => { const hook = createHook(() => ({ cancel: true })); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -196,7 +197,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } return undefined; }); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -214,13 +215,13 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const afterEvent = compactEvents[0]; if (afterEvent.type === "session_compact") { expect(afterEvent.compactionEntry.summary).toBe(customSummary); - expect(afterEvent.fromHook).toBe(true); + expect(afterEvent.fromExtension).toBe(true); } }, 120000); it("should include entries in compact event after compaction is saved", async () => { const hook = createHook(); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -269,7 +270,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { setAppendEntryHandler: () => {}, }; - createSession([throwingHook]); + await createSession([throwingHook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -281,7 +282,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const compactEvents = capturedEvents.filter((e): e is SessionCompactEvent => e.type === "session_compact"); expect(compactEvents.length).toBe(1); - expect(compactEvents[0].fromHook).toBe(false); + expect(compactEvents[0].fromExtension).toBe(false); }, 120000); it("should call multiple hooks in order", async () => { @@ -345,7 +346,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { setAppendEntryHandler: () => {}, }; - createSession([hook1, hook2]); + await createSession([hook1, hook2]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -362,7 +363,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { capturedBeforeEvent = event; return undefined; }); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); @@ -409,7 +410,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { } return undefined; }); - createSession([hook]); + await createSession([hook]); await session.prompt("What is 2+2? Reply with just the number."); await session.agent.waitForIdle(); diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts new file mode 100644 index 000000000..5571020ea --- /dev/null +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -0,0 +1,207 @@ +/** + * Test for compaction with thinking models. + * + * Tests both: + * - Claude via Antigravity (google-gemini-cli API) + * - Claude via real Anthropic API (anthropic-messages API) + * + * Reproduces issue where compact fails when maxTokens < thinkingBudget. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { getModel, type Model } from "@oh-my-pi/pi-ai"; +import { AgentSession } from "../src/core/agent-session"; +import { AuthStorage } from "../src/core/auth-storage"; +import { ModelRegistry } from "../src/core/model-registry"; +import { SessionManager } from "../src/core/session-manager"; +import { SettingsManager } from "../src/core/settings-manager"; +import { codingTools } from "../src/core/tools/index"; +import { API_KEY } from "./utilities"; + +// Check for auth +const HAS_ANTIGRAVITY_AUTH = false; // OAuth not available in test environment +const HAS_ANTHROPIC_AUTH = !!API_KEY; + +describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigravity)", () => { + let session: AgentSession; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-thinking-compaction-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createSession( + modelId: "claude-opus-4-5-thinking" | "claude-sonnet-4-5", + thinkingLevel: ThinkingLevel = "high", + ) { + const model = getModel("google-antigravity", modelId); + if (!model) { + throw new Error(`Model not found: google-antigravity/${modelId}`); + } + + const agent = new Agent({ + getApiKey: () => API_KEY, + initialState: { + model, + systemPrompt: "You are a helpful assistant. Be concise.", + tools: codingTools, + thinkingLevel, + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + // Use minimal keepRecentTokens so small test conversations have something to summarize + // settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + session.subscribe(() => {}); + + return session; + } + + it("should compact successfully with claude-opus-4-5-thinking and thinking level high", async () => { + createSession("claude-opus-4-5-thinking", "high"); + + // Send a simple prompt + await session.prompt("Write down the first 10 prime numbers."); + await session.agent.waitForIdle(); + + // Verify we got a response + const messages = session.messages; + expect(messages.length).toBeGreaterThan(0); + + const assistantMessages = messages.filter((m) => m.role === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Now try to compact - this should not throw + const result = await session.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + expect(result.tokensBefore).toBeGreaterThan(0); + + // Verify session is still usable after compaction + const messagesAfterCompact = session.messages; + expect(messagesAfterCompact.length).toBeGreaterThan(0); + expect(messagesAfterCompact[0].role).toBe("compactionSummary"); + }, 180000); + + it("should compact successfully with claude-sonnet-4-5 (non-thinking) for comparison", async () => { + createSession("claude-sonnet-4-5", "off"); + + await session.prompt("Write down the first 10 prime numbers."); + await session.agent.waitForIdle(); + + const messages = session.messages; + expect(messages.length).toBeGreaterThan(0); + + const result = await session.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + }, 180000); +}); + +// ============================================================================ +// Real Anthropic API tests (for comparison) +// ============================================================================ + +describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic)", () => { + let session: AgentSession; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-thinking-compaction-anthropic-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createSession(model: Model, thinkingLevel: ThinkingLevel = "high") { + const agent = new Agent({ + getApiKey: () => API_KEY, + initialState: { + model, + systemPrompt: "You are a helpful assistant. Be concise.", + tools: codingTools, + thinkingLevel, + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + session.subscribe(() => {}); + + return session; + } + + it("should compact successfully with claude-3-7-sonnet and thinking level high", async () => { + const model = getModel("anthropic", "claude-3-7-sonnet-latest")!; + createSession(model, "high"); + + // Send a simple prompt + await session.prompt("Write down the first 10 prime numbers."); + await session.agent.waitForIdle(); + + // Verify we got a response + const messages = session.messages; + expect(messages.length).toBeGreaterThan(0); + + const assistantMessages = messages.filter((m) => m.role === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); + + // Now try to compact - this should not throw + const result = await session.compact(); + + expect(result.summary).toBeDefined(); + expect(result.summary.length).toBeGreaterThan(0); + expect(result.tokensBefore).toBeGreaterThan(0); + + // Verify session is still usable after compaction + const messagesAfterCompact = session.messages; + expect(messagesAfterCompact.length).toBeGreaterThan(0); + expect(messagesAfterCompact[0].role).toBe("compactionSummary"); + }, 180000); +}); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index f80ee6fac..33d2f04f6 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -1,9 +1,9 @@ +import { beforeEach, describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { getModel } from "@oh-my-pi/pi-ai"; -import { beforeEach, describe, expect, it } from "vitest"; import { type CompactionSettings, calculateContextTokens, diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts new file mode 100644 index 000000000..66773fc7b --- /dev/null +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -0,0 +1,446 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("extensions discovery", () => { + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(tmpdir(), "pi-ext-test-")); + extensionsDir = path.join(tempDir, ".omp", "extensions"); + fs.mkdirSync(extensionsDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + const extensionCode = ` + export default function(pi) { + pi.registerCommand("test", { handler: async () => {} }); + } + `; + + const extensionCodeWithTool = (toolName: string) => ` + export default function(pi) { + const { Type } = pi.typebox; + pi.registerTool({ + name: "${toolName}", + label: "${toolName}", + description: "Test tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + } + `; + + it("discovers direct .ts files in extensions/", async () => { + fs.writeFileSync(path.join(extensionsDir, "foo.ts"), extensionCode); + fs.writeFileSync(path.join(extensionsDir, "bar.ts"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(2); + expect(result.extensions.map((e) => path.basename(e.path)).sort()).toEqual(["bar.ts", "foo.ts"]); + }); + + it("discovers direct .js files in extensions/", async () => { + fs.writeFileSync(path.join(extensionsDir, "foo.js"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(path.basename(result.extensions[0].path)).toBe("foo.js"); + }); + + it("discovers subdirectory with index.ts", async () => { + const subdir = path.join(extensionsDir, "my-extension"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("my-extension"); + expect(result.extensions[0].path).toContain("index.ts"); + }); + + it("discovers subdirectory with index.js", async () => { + const subdir = path.join(extensionsDir, "my-extension"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("index.js"); + }); + + it("prefers index.ts over index.js", async () => { + const subdir = path.join(extensionsDir, "my-extension"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); + fs.writeFileSync(path.join(subdir, "index.js"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("index.ts"); + }); + + it("discovers subdirectory with package.json pi field", async () => { + const subdir = path.join(extensionsDir, "my-package"); + const srcDir = path.join(subdir, "src"); + fs.mkdirSync(subdir); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "my-package", + pi: { + extensions: ["./src/main.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("src"); + expect(result.extensions[0].path).toContain("main.ts"); + }); + + it("package.json can declare multiple extensions", async () => { + const subdir = path.join(extensionsDir, "my-package"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "ext1.ts"), extensionCode); + fs.writeFileSync(path.join(subdir, "ext2.ts"), extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "my-package", + pi: { + extensions: ["./ext1.ts", "./ext2.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(2); + }); + + it("package.json with pi field takes precedence over index.ts", async () => { + const subdir = path.join(extensionsDir, "my-package"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "index.ts"), extensionCodeWithTool("from-index")); + fs.writeFileSync(path.join(subdir, "custom.ts"), extensionCodeWithTool("from-custom")); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "my-package", + pi: { + extensions: ["./custom.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("custom.ts"); + // Verify the right tool was registered + expect(result.extensions[0].tools.has("from-custom")).toBe(true); + expect(result.extensions[0].tools.has("from-index")).toBe(false); + }); + + it("ignores package.json without pi field, falls back to index.ts", async () => { + const subdir = path.join(extensionsDir, "my-package"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "index.ts"), extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "my-package", + version: "1.0.0", + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("index.ts"); + }); + + it("ignores subdirectory without index or package.json", async () => { + const subdir = path.join(extensionsDir, "not-an-extension"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "helper.ts"), extensionCode); + fs.writeFileSync(path.join(subdir, "utils.ts"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(0); + }); + + it("does not recurse beyond one level", async () => { + const subdir = path.join(extensionsDir, "container"); + const nested = path.join(subdir, "nested"); + fs.mkdirSync(subdir); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, "index.ts"), extensionCode); + // No index.ts or package.json in container/ + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(0); + }); + + it("handles mixed direct files and subdirectories", async () => { + // Direct file + fs.writeFileSync(path.join(extensionsDir, "direct.ts"), extensionCode); + + // Subdirectory with index + const subdir1 = path.join(extensionsDir, "with-index"); + fs.mkdirSync(subdir1); + fs.writeFileSync(path.join(subdir1, "index.ts"), extensionCode); + + // Subdirectory with package.json + const subdir2 = path.join(extensionsDir, "with-manifest"); + fs.mkdirSync(subdir2); + fs.writeFileSync(path.join(subdir2, "entry.ts"), extensionCode); + fs.writeFileSync(path.join(subdir2, "package.json"), JSON.stringify({ pi: { extensions: ["./entry.ts"] } })); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(3); + }); + + it("skips non-existent paths declared in package.json", async () => { + const subdir = path.join(extensionsDir, "my-package"); + fs.mkdirSync(subdir); + fs.writeFileSync(path.join(subdir, "exists.ts"), extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + pi: { + extensions: ["./exists.ts", "./missing.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("exists.ts"); + }); + + it("loads extensions and registers commands", async () => { + fs.writeFileSync(path.join(extensionsDir, "with-command.ts"), extensionCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].commands.has("test")).toBe(true); + }); + + it("loads extensions and registers tools", async () => { + fs.writeFileSync(path.join(extensionsDir, "with-tool.ts"), extensionCodeWithTool("my-tool")); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].tools.has("my-tool")).toBe(true); + }); + + it("reports errors for invalid extension code", async () => { + fs.writeFileSync(path.join(extensionsDir, "invalid.ts"), "this is not valid typescript export"); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].path).toContain("invalid.ts"); + expect(result.extensions).toHaveLength(0); + }); + + it("handles explicitly configured paths", async () => { + const customPath = path.join(tempDir, "custom-location", "my-ext.ts"); + fs.mkdirSync(path.dirname(customPath), { recursive: true }); + fs.writeFileSync(customPath, extensionCode); + + const result = await discoverAndLoadExtensions([customPath], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("my-ext.ts"); + }); + + it("resolves 3rd party npm dependencies (chalk)", async () => { + // Load the real chalk-logger extension from examples + const chalkLoggerPath = path.resolve(__dirname, "../examples/extensions/chalk-logger.ts"); + + const result = await discoverAndLoadExtensions([chalkLoggerPath], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("chalk-logger.ts"); + // The extension registers event handlers, not commands/tools + expect(result.extensions[0].handlers.size).toBeGreaterThan(0); + }); + + it("resolves dependencies from extension's own node_modules", async () => { + // Load extension that has its own package.json and node_modules with 'ms' package + const extPath = path.resolve(__dirname, "../examples/extensions/with-deps"); + + const result = await discoverAndLoadExtensions([extPath], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("with-deps"); + // The extension registers a 'parse_duration' tool + expect(result.extensions[0].tools.has("parse_duration")).toBe(true); + }); + + it("registers message renderers", async () => { + const extCode = ` + export default function(pi) { + pi.registerMessageRenderer("my-custom-type", (message, options, theme) => { + return null; // Use default rendering + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-renderer.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].messageRenderers.has("my-custom-type")).toBe(true); + }); + + it("reports error when extension throws during initialization", async () => { + const extCode = ` + export default function(pi) { + throw new Error("Initialization failed!"); + } + `; + fs.writeFileSync(path.join(extensionsDir, "throws.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain("Initialization failed!"); + expect(result.extensions).toHaveLength(0); + }); + + it("reports error when extension has no default export", async () => { + const extCode = ` + export function notDefault(pi) { + pi.registerCommand("test", { handler: async () => {} }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "no-default.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].error).toContain("must export a default function"); + expect(result.extensions).toHaveLength(0); + }); + + it("allows multiple extensions to register different tools", async () => { + fs.writeFileSync(path.join(extensionsDir, "tool-a.ts"), extensionCodeWithTool("tool-a")); + fs.writeFileSync(path.join(extensionsDir, "tool-b.ts"), extensionCodeWithTool("tool-b")); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(2); + + const allTools = new Set(); + for (const ext of result.extensions) { + for (const name of ext.tools.keys()) { + allTools.add(name); + } + } + expect(allTools.has("tool-a")).toBe(true); + expect(allTools.has("tool-b")).toBe(true); + }); + + it("loads extension with event handlers", async () => { + const extCode = ` + export default function(pi) { + pi.on("agent_start", async () => {}); + pi.on("tool_call", async (event) => undefined); + pi.on("agent_end", async () => {}); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-handlers.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].handlers.has("agent_start")).toBe(true); + expect(result.extensions[0].handlers.has("tool_call")).toBe(true); + expect(result.extensions[0].handlers.has("agent_end")).toBe(true); + }); + + it("loads extension with shortcuts", async () => { + const extCode = ` + export default function(pi) { + pi.registerShortcut("ctrl+t", { + description: "Test shortcut", + handler: async (ctx) => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-shortcut.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].shortcuts.has("ctrl+t")).toBe(true); + }); + + it("loads extension with flags", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--my-flag", { + description: "My custom flag", + handler: async (value) => {}, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "with-flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].flags.has("--my-flag")).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts new file mode 100644 index 000000000..58bfa3ea4 --- /dev/null +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for ExtensionRunner - conflict detection, error handling, tool wrapping. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "../src/core/auth-storage"; +import { discoverAndLoadExtensions } from "../src/core/extensions/loader"; +import { ExtensionRunner } from "../src/core/extensions/runner"; +import { ModelRegistry } from "../src/core/model-registry"; +import { SessionManager } from "../src/core/session-manager"; + +describe("ExtensionRunner", () => { + let tempDir: string; + let extensionsDir: string; + let sessionManager: SessionManager; + let modelRegistry: ModelRegistry; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "pi-runner-test-")); + extensionsDir = join(tempDir, ".omp", "extensions"); + mkdirSync(extensionsDir, { recursive: true }); + sessionManager = SessionManager.inMemory(); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + modelRegistry = new ModelRegistry(authStorage); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("shortcut conflicts", () => { + it("warns when extension shortcut conflicts with built-in", async () => { + const extCode = ` + export default function(pi) { + pi.registerShortcut("ctrl+c", { + description: "Conflicts with built-in", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "conflict.ts"), extCode); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const shortcuts = runner.getShortcuts(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); + expect(shortcuts.has("ctrl+c")).toBe(false); + + warnSpy.mockRestore(); + }); + + it("warns when two extensions register same shortcut", async () => { + // Use a non-reserved shortcut + const extCode1 = ` + export default function(pi) { + pi.registerShortcut("ctrl+shift+x", { + description: "First extension", + handler: async () => {}, + }); + } + `; + const extCode2 = ` + export default function(pi) { + pi.registerShortcut("ctrl+shift+x", { + description: "Second extension", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "ext1.ts"), extCode1); + writeFileSync(join(extensionsDir, "ext2.ts"), extCode2); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const shortcuts = runner.getShortcuts(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict")); + // Last one wins + expect(shortcuts.has("ctrl+shift+x")).toBe(true); + + warnSpy.mockRestore(); + }); + }); + + describe("tool collection", () => { + it("collects tools from multiple extensions", async () => { + const toolCode = (name: string) => ` + export default function(pi) { + const { Type } = pi.typebox; + pi.registerTool({ + name: "${name}", + label: "${name}", + description: "Test tool", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + } + `; + writeFileSync(join(extensionsDir, "tool-a.ts"), toolCode("tool_a")); + writeFileSync(join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const tools = runner.getAllRegisteredTools(); + + expect(tools.length).toBe(2); + expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]); + }); + }); + + describe("command collection", () => { + it("collects commands from multiple extensions", async () => { + const cmdCode = (name: string) => ` + export default function(pi) { + pi.registerCommand("${name}", { + description: "Test command", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "cmd-a.ts"), cmdCode("cmd-a")); + writeFileSync(join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const commands = runner.getRegisteredCommands(); + + expect(commands.length).toBe(2); + expect(commands.map((c) => c.name).sort()).toEqual(["cmd-a", "cmd-b"]); + }); + + it("gets command by name", async () => { + const cmdCode = ` + export default function(pi) { + pi.registerCommand("my-cmd", { + description: "My command", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "cmd.ts"), cmdCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const cmd = runner.getCommand("my-cmd"); + expect(cmd).toBeDefined(); + expect(cmd?.name).toBe("my-cmd"); + expect(cmd?.description).toBe("My command"); + + const missing = runner.getCommand("not-exists"); + expect(missing).toBeUndefined(); + }); + }); + + describe("error handling", () => { + it("calls error listeners when handler throws", async () => { + const extCode = ` + export default function(pi) { + pi.on("context", async () => { + throw new Error("Handler error!"); + }); + } + `; + writeFileSync(join(extensionsDir, "throws.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const errors: Array<{ extensionPath: string; event: string; error: string }> = []; + runner.onError((err) => { + errors.push(err); + }); + + // Emit context event which will trigger the throwing handler + await runner.emitContext([]); + + expect(errors.length).toBe(1); + expect(errors[0].error).toContain("Handler error!"); + expect(errors[0].event).toBe("context"); + }); + }); + + describe("message renderers", () => { + it("gets message renderer by type", async () => { + const extCode = ` + export default function(pi) { + pi.registerMessageRenderer("my-type", (message, options, theme) => null); + } + `; + writeFileSync(join(extensionsDir, "renderer.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + const renderer = runner.getMessageRenderer("my-type"); + expect(renderer).toBeDefined(); + + const missing = runner.getMessageRenderer("not-exists"); + expect(missing).toBeUndefined(); + }); + }); + + describe("flags", () => { + it("collects flags from extensions", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--my-flag", { + description: "My flag", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "with-flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const flags = runner.getFlags(); + + expect(flags.has("--my-flag")).toBe(true); + }); + + it("can set flag values", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("--test-flag", { + description: "Test flag", + handler: async () => {}, + }); + } + `; + writeFileSync(join(extensionsDir, "flag.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + // Setting a flag value should not throw + runner.setFlagValue("--test-flag", true); + + // The flag values are stored in the extension's flagValues map + const ext = result.extensions[0]; + expect(ext.flagValues.get("--test-flag")).toBe(true); + }); + }); + + describe("hasHandlers", () => { + it("returns true when handlers exist for event type", async () => { + const extCode = ` + export default function(pi) { + pi.on("tool_call", async () => undefined); + } + `; + writeFileSync(join(extensionsDir, "handler.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir); + const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + + expect(runner.hasHandlers("tool_call")).toBe(true); + expect(runner.hasHandlers("agent_end")).toBe(false); + }); + }); +}); diff --git a/packages/coding-agent/test/fuzzy.test.ts b/packages/coding-agent/test/fuzzy.test.ts index 1ac7d6503..2476918c5 100644 --- a/packages/coding-agent/test/fuzzy.test.ts +++ b/packages/coding-agent/test/fuzzy.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test } from "bun:test"; import { fuzzyFilter, fuzzyMatch } from "../src/utils/fuzzy"; describe("fuzzyMatch", () => { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 0bf0ead82..efb871818 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,5 +1,5 @@ +import { beforeAll, describe, expect, test, vi } from "bun:test"; import { Container } from "@oh-my-pi/pi-tui"; -import { beforeAll, describe, expect, test, vi } from "vitest"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode"; import { initTheme } from "../src/modes/interactive/theme/theme"; diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts new file mode 100644 index 000000000..dcf4a0dd2 --- /dev/null +++ b/packages/coding-agent/test/model-registry.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AuthStorage } from "../src/core/auth-storage"; +import { ModelRegistry } from "../src/core/model-registry"; + +describe("ModelRegistry", () => { + let tempDir: string; + let modelsJsonPath: string; + let authStorage: AuthStorage; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-test-model-registry-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + modelsJsonPath = join(tempDir, "models.json"); + authStorage = new AuthStorage(join(tempDir, "auth.json")); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + /** Create minimal provider config */ + function providerConfig( + baseUrl: string, + models: Array<{ id: string; name?: string }>, + api: string = "anthropic-messages", + ) { + return { + baseUrl, + apiKey: "TEST_KEY", + api, + models: models.map((m) => ({ + id: m.id, + name: m.name ?? m.id, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 100000, + maxTokens: 8000, + })), + }; + } + + function writeModelsJson(providers: Record>) { + writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + } + + function getModelsForProvider(registry: ModelRegistry, provider: string) { + return registry.getAll().filter((m) => m.provider === provider); + } + + /** Create a baseUrl-only override (no custom models) */ + function overrideConfig(baseUrl: string, headers?: Record) { + return { baseUrl, ...(headers && { headers }) }; + } + + /** Write raw providers config (for mixed override/replacement scenarios) */ + function writeRawModelsJson(providers: Record) { + writeFileSync(modelsJsonPath, JSON.stringify({ providers })); + } + + describe("baseUrl override (no custom models)", () => { + test("overriding baseUrl keeps all built-in models", () => { + writeRawModelsJson({ + anthropic: overrideConfig("https://my-proxy.example.com/v1"), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const anthropicModels = getModelsForProvider(registry, "anthropic"); + + // Should have multiple built-in models, not just one + expect(anthropicModels.length).toBeGreaterThan(1); + expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); + }); + + test("overriding baseUrl changes URL on all built-in models", () => { + writeRawModelsJson({ + anthropic: overrideConfig("https://my-proxy.example.com/v1"), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const anthropicModels = getModelsForProvider(registry, "anthropic"); + + // All models should have the new baseUrl + for (const model of anthropicModels) { + expect(model.baseUrl).toBe("https://my-proxy.example.com/v1"); + } + }); + + test("overriding headers merges with model headers", () => { + writeRawModelsJson({ + anthropic: overrideConfig("https://my-proxy.example.com/v1", { + "X-Custom-Header": "custom-value", + }), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const anthropicModels = getModelsForProvider(registry, "anthropic"); + + for (const model of anthropicModels) { + expect(model.headers?.["X-Custom-Header"]).toBe("custom-value"); + } + }); + + test("baseUrl-only override does not affect other providers", () => { + writeRawModelsJson({ + anthropic: overrideConfig("https://my-proxy.example.com/v1"), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const googleModels = getModelsForProvider(registry, "google"); + + // Google models should still have their original baseUrl + expect(googleModels.length).toBeGreaterThan(0); + expect(googleModels[0].baseUrl).not.toBe("https://my-proxy.example.com/v1"); + }); + + test("can mix baseUrl override and full replacement", () => { + writeRawModelsJson({ + // baseUrl-only for anthropic + anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"), + // Full replacement for google + google: providerConfig( + "https://google-proxy.example.com/v1", + [{ id: "gemini-custom" }], + "google-generative-ai", + ), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + // Anthropic: multiple built-in models with new baseUrl + const anthropicModels = getModelsForProvider(registry, "anthropic"); + expect(anthropicModels.length).toBeGreaterThan(1); + expect(anthropicModels[0].baseUrl).toBe("https://anthropic-proxy.example.com/v1"); + + // Google: single custom model + const googleModels = getModelsForProvider(registry, "google"); + expect(googleModels).toHaveLength(1); + expect(googleModels[0].id).toBe("gemini-custom"); + }); + + test("refresh() picks up baseUrl override changes", () => { + writeRawModelsJson({ + anthropic: overrideConfig("https://first-proxy.example.com/v1"), + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(getModelsForProvider(registry, "anthropic")[0].baseUrl).toBe("https://first-proxy.example.com/v1"); + + // Update and refresh + writeRawModelsJson({ + anthropic: overrideConfig("https://second-proxy.example.com/v1"), + }); + registry.refresh(); + + expect(getModelsForProvider(registry, "anthropic")[0].baseUrl).toBe("https://second-proxy.example.com/v1"); + }); + }); + + describe("provider replacement (with custom models)", () => { + test("custom provider with same name as built-in replaces built-in models", () => { + writeModelsJson({ + anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const anthropicModels = getModelsForProvider(registry, "anthropic"); + + expect(anthropicModels).toHaveLength(1); + expect(anthropicModels[0].id).toBe("claude-custom"); + expect(anthropicModels[0].baseUrl).toBe("https://my-proxy.example.com/v1"); + }); + + test("custom provider with same name as built-in does not affect other built-in providers", () => { + writeModelsJson({ + anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(getModelsForProvider(registry, "google").length).toBeGreaterThan(0); + expect(getModelsForProvider(registry, "openai").length).toBeGreaterThan(0); + }); + + test("multiple built-in providers can be overridden", () => { + writeModelsJson({ + anthropic: providerConfig("https://anthropic-proxy.example.com/v1", [{ id: "claude-proxy" }]), + google: providerConfig( + "https://google-proxy.example.com/v1", + [{ id: "gemini-proxy" }], + "google-generative-ai", + ), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const anthropicModels = getModelsForProvider(registry, "anthropic"); + const googleModels = getModelsForProvider(registry, "google"); + + expect(anthropicModels).toHaveLength(1); + expect(anthropicModels[0].id).toBe("claude-proxy"); + expect(anthropicModels[0].baseUrl).toBe("https://anthropic-proxy.example.com/v1"); + + expect(googleModels).toHaveLength(1); + expect(googleModels[0].id).toBe("gemini-proxy"); + expect(googleModels[0].baseUrl).toBe("https://google-proxy.example.com/v1"); + }); + + test("refresh() reloads overrides from disk", () => { + writeModelsJson({ + anthropic: providerConfig("https://first-proxy.example.com/v1", [{ id: "claude-first" }]), + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(getModelsForProvider(registry, "anthropic")[0].id).toBe("claude-first"); + + // Update and refresh + writeModelsJson({ + anthropic: providerConfig("https://second-proxy.example.com/v1", [{ id: "claude-second" }]), + }); + registry.refresh(); + + const anthropicModels = getModelsForProvider(registry, "anthropic"); + expect(anthropicModels[0].id).toBe("claude-second"); + expect(anthropicModels[0].baseUrl).toBe("https://second-proxy.example.com/v1"); + }); + + test("removing override from models.json restores built-in provider", () => { + writeModelsJson({ + anthropic: providerConfig("https://proxy.example.com/v1", [{ id: "claude-custom" }]), + }); + const registry = new ModelRegistry(authStorage, modelsJsonPath); + + expect(getModelsForProvider(registry, "anthropic")).toHaveLength(1); + + // Remove override and refresh + writeModelsJson({}); + registry.refresh(); + + const anthropicModels = getModelsForProvider(registry, "anthropic"); + expect(anthropicModels.length).toBeGreaterThan(1); + expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); + }); + }); +}); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index f71da1e42..33bcc8e81 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, test } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai"; -import { describe, expect, test } from "vitest"; import { parseModelPattern } from "../src/core/model-resolver"; // Mock models for testing @@ -100,7 +100,8 @@ describe("parseModelPattern", () => { }); test("all valid thinking levels work", () => { - for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) { + const levels = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; + for (const level of levels) { const result = parseModelPattern(`sonnet:${level}`, allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe(level); diff --git a/packages/coding-agent/test/prompt-templates.test.ts b/packages/coding-agent/test/prompt-templates.test.ts new file mode 100644 index 000000000..051f85284 --- /dev/null +++ b/packages/coding-agent/test/prompt-templates.test.ts @@ -0,0 +1,277 @@ +/** + * Tests for prompt template argument parsing and substitution. + * + * Tests verify: + * - Argument parsing with quotes and special characters + * - Placeholder substitution ($1, $2, $@, $ARGUMENTS) + * - No recursive substitution of patterns in argument values + * - Edge cases and integration between parsing and substitution + */ + +import { describe, expect, test } from "bun:test"; +import { parseCommandArgs, substituteArgs } from "../src/core/prompt-templates"; + +// ============================================================================ +// substituteArgs +// ============================================================================ + +describe("substituteArgs", () => { + test("should replace $ARGUMENTS with all args joined", () => { + expect(substituteArgs("Test: $ARGUMENTS", ["a", "b", "c"])).toBe("Test: a b c"); + }); + + test("should replace $@ with all args joined", () => { + expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c"); + }); + + test("should replace $@ and $ARGUMENTS identically", () => { + const args = ["foo", "bar", "baz"]; + expect(substituteArgs("Test: $@", args)).toBe(substituteArgs("Test: $ARGUMENTS", args)); + }); + + // CRITICAL: argument values containing patterns should remain literal + test("should NOT recursively substitute patterns in argument values", () => { + expect(substituteArgs("$ARGUMENTS", ["$1", "$ARGUMENTS"])).toBe("$1 $ARGUMENTS"); + expect(substituteArgs("$@", ["$100", "$1"])).toBe("$100 $1"); + expect(substituteArgs("$ARGUMENTS", ["$100", "$1"])).toBe("$100 $1"); + }); + + test("should support mixed $1, $2, and $ARGUMENTS", () => { + expect(substituteArgs("$1: $ARGUMENTS", ["prefix", "a", "b"])).toBe("prefix: prefix a b"); + }); + + test("should support mixed $1, $2, and $@", () => { + expect(substituteArgs("$1: $@", ["prefix", "a", "b"])).toBe("prefix: prefix a b"); + }); + + test("should handle empty arguments array with $ARGUMENTS", () => { + expect(substituteArgs("Test: $ARGUMENTS", [])).toBe("Test: "); + }); + + test("should handle empty arguments array with $@", () => { + expect(substituteArgs("Test: $@", [])).toBe("Test: "); + }); + + test("should handle empty arguments array with $1", () => { + expect(substituteArgs("Test: $1", [])).toBe("Test: "); + }); + + test("should handle multiple occurrences of $ARGUMENTS", () => { + expect(substituteArgs("$ARGUMENTS and $ARGUMENTS", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle multiple occurrences of $@", () => { + expect(substituteArgs("$@ and $@", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle mixed occurrences of $@ and $ARGUMENTS", () => { + expect(substituteArgs("$@ and $ARGUMENTS", ["a", "b"])).toBe("a b and a b"); + }); + + test("should handle special characters in arguments", () => { + // Note: $100 in argument doesn't get partially matched - full strings are substituted + expect(substituteArgs("$1 $2: $ARGUMENTS", ["arg100", "@user"])).toBe("arg100 @user: arg100 @user"); + }); + + test("should handle out-of-range numbered placeholders", () => { + // Note: Out-of-range placeholders become empty strings (preserving spaces from template) + expect(substituteArgs("$1 $2 $3 $4 $5", ["a", "b"])).toBe("a b "); + }); + + test("should handle unicode characters", () => { + expect(substituteArgs("$ARGUMENTS", ["日本語", "🎉", "café"])).toBe("日本語 🎉 café"); + }); + + test("should preserve newlines and tabs in argument values", () => { + expect(substituteArgs("$1 $2", ["line1\nline2", "tab\tthere"])).toBe("line1\nline2 tab\tthere"); + }); + + test("should handle consecutive dollar patterns", () => { + expect(substituteArgs("$1$2", ["a", "b"])).toBe("ab"); + }); + + test("should handle quoted arguments with spaces", () => { + expect(substituteArgs("$ARGUMENTS", ["first arg", "second arg"])).toBe("first arg second arg"); + }); + + test("should handle single argument with $ARGUMENTS", () => { + expect(substituteArgs("Test: $ARGUMENTS", ["only"])).toBe("Test: only"); + }); + + test("should handle single argument with $@", () => { + expect(substituteArgs("Test: $@", ["only"])).toBe("Test: only"); + }); + + test("should handle $0 (zero index)", () => { + expect(substituteArgs("$0", ["a", "b"])).toBe(""); + }); + + test("should handle decimal number in pattern (only integer part matches)", () => { + expect(substituteArgs("$1.5", ["a"])).toBe("a.5"); + }); + + test("should handle $ARGUMENTS as part of word", () => { + expect(substituteArgs("pre$ARGUMENTS", ["a", "b"])).toBe("prea b"); + }); + + test("should handle $@ as part of word", () => { + expect(substituteArgs("pre$@", ["a", "b"])).toBe("prea b"); + }); + + test("should handle empty arguments in middle of list", () => { + expect(substituteArgs("$ARGUMENTS", ["a", "", "c"])).toBe("a c"); + }); + + test("should handle trailing and leading spaces in arguments", () => { + expect(substituteArgs("$ARGUMENTS", [" leading ", "trailing "])).toBe(" leading trailing "); + }); + + test("should handle argument containing pattern partially", () => { + expect(substituteArgs("Prefix $ARGUMENTS suffix", ["ARGUMENTS"])).toBe("Prefix ARGUMENTS suffix"); + }); + + test("should handle non-matching patterns", () => { + expect(substituteArgs("$A $$ $ $ARGS", ["a"])).toBe("$A $$ $ $ARGS"); + }); + + test("should handle case variations (case-sensitive)", () => { + expect(substituteArgs("$arguments $Arguments $ARGUMENTS", ["a", "b"])).toBe("$arguments $Arguments a b"); + }); + + test("should handle both syntaxes in same command with same result", () => { + const args = ["x", "y", "z"]; + const result1 = substituteArgs("$@ and $ARGUMENTS", args); + const result2 = substituteArgs("$ARGUMENTS and $@", args); + expect(result1).toBe(result2); + expect(result1).toBe("x y z and x y z"); + }); + + test("should handle very long argument lists", () => { + const args = Array.from({ length: 100 }, (_, i) => `arg${i}`); + const result = substituteArgs("$ARGUMENTS", args); + expect(result).toBe(args.join(" ")); + }); + + test("should handle numbered placeholders with single digit", () => { + expect(substituteArgs("$1 $2 $3", ["a", "b", "c"])).toBe("a b c"); + }); + + test("should handle numbered placeholders with multiple digits", () => { + const args = Array.from({ length: 15 }, (_, i) => `val${i}`); + expect(substituteArgs("$10 $12 $15", args)).toBe("val9 val11 val14"); + }); + + test("should handle escaped dollar signs (literal backslash preserved)", () => { + // Note: No escape mechanism exists - backslash is treated literally + expect(substituteArgs("Price: \\$100", [])).toBe("Price: \\"); + }); + + test("should handle mixed numbered and wildcard placeholders", () => { + expect(substituteArgs("$1: $@ ($ARGUMENTS)", ["first", "second", "third"])).toBe( + "first: first second third (first second third)", + ); + }); + + test("should handle command with no placeholders", () => { + expect(substituteArgs("Just plain text", ["a", "b"])).toBe("Just plain text"); + }); + + test("should handle command with only placeholders", () => { + expect(substituteArgs("$1 $2 $@", ["a", "b", "c"])).toBe("a b a b c"); + }); +}); + +// ============================================================================ +// parseCommandArgs +// ============================================================================ + +describe("parseCommandArgs", () => { + test("should parse simple space-separated arguments", () => { + expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]); + }); + + test("should parse quoted arguments with spaces", () => { + expect(parseCommandArgs('"first arg" second')).toEqual(["first arg", "second"]); + }); + + test("should parse single-quoted arguments", () => { + expect(parseCommandArgs("'first arg' second")).toEqual(["first arg", "second"]); + }); + + test("should parse mixed quote styles", () => { + expect(parseCommandArgs('"double" \'single\' "double again"')).toEqual(["double", "single", "double again"]); + }); + + test("should handle empty string", () => { + expect(parseCommandArgs("")).toEqual([]); + }); + + test("should handle extra spaces", () => { + expect(parseCommandArgs("a b c")).toEqual(["a", "b", "c"]); + }); + + test("should handle tabs as separators", () => { + expect(parseCommandArgs("a\tb\tc")).toEqual(["a", "b", "c"]); + }); + + test("should handle quoted empty string", () => { + // Note: Empty quotes are skipped by current implementation + expect(parseCommandArgs('"" " "')).toEqual([" "]); + }); + + test("should handle arguments with special characters", () => { + expect(parseCommandArgs("$100 @user #tag")).toEqual(["$100", "@user", "#tag"]); + }); + + test("should handle unicode characters", () => { + expect(parseCommandArgs("日本語 🎉 café")).toEqual(["日本語", "🎉", "café"]); + }); + + test("should handle newlines in arguments", () => { + expect(parseCommandArgs('"line1\nline2" second')).toEqual(["line1\nline2", "second"]); + }); + + test("should handle escaped quotes inside quoted strings", () => { + // Note: This implementation doesn't handle escaped quotes - backslash is literal + expect(parseCommandArgs('"quoted \\"text\\""')).toEqual(["quoted \\text\\"]); + }); + + test("should handle trailing spaces", () => { + expect(parseCommandArgs("a b c ")).toEqual(["a", "b", "c"]); + }); + + test("should handle leading spaces", () => { + expect(parseCommandArgs(" a b c")).toEqual(["a", "b", "c"]); + }); +}); + +// ============================================================================ +// Integration +// ============================================================================ + +describe("parseCommandArgs + substituteArgs integration", () => { + test("should parse and substitute together correctly", () => { + const input = 'Button "onClick handler" "disabled support"'; + const args = parseCommandArgs(input); + const template = "Create component $1 with features: $ARGUMENTS"; + const result = substituteArgs(template, args); + expect(result).toBe("Create component Button with features: Button onClick handler disabled support"); + }); + + test("should handle the example from README", () => { + const input = 'Button "onClick handler" "disabled support"'; + const args = parseCommandArgs(input); + const template = "Create a React component named $1 with features: $ARGUMENTS"; + const result = substituteArgs(template, args); + expect(result).toBe( + "Create a React component named Button with features: Button onClick handler disabled support", + ); + }); + + test("should produce same result with $@ and $ARGUMENTS", () => { + const args = parseCommandArgs("feature1 feature2 feature3"); + const template1 = "Implement: $@"; + const template2 = "Implement: $ARGUMENTS"; + expect(substituteArgs(template1, args)).toBe(substituteArgs(template2, args)); + }); +}); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 5770ef47d..54d9229d4 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -1,8 +1,8 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentEvent } from "@oh-my-pi/pi-agent-core"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { RpcClient } from "../src/modes/rpc/rpc-client"; /** diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index b3c15a0c6..fae97ca72 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { type BranchSummaryEntry, buildSessionContext, diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 3456e152b..96820e826 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -1,7 +1,7 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findMostRecentSession, loadEntriesFromFile } from "../../src/core/session-manager"; describe("loadEntriesFromFile", () => { diff --git a/packages/coding-agent/test/session-manager/labels.test.ts b/packages/coding-agent/test/session-manager/labels.test.ts index fab7f0b35..67a4e2991 100644 --- a/packages/coding-agent/test/session-manager/labels.test.ts +++ b/packages/coding-agent/test/session-manager/labels.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { type LabelEntry, SessionManager } from "../../src/core/session-manager"; describe("SessionManager labels", () => { diff --git a/packages/coding-agent/test/session-manager/migration.test.ts b/packages/coding-agent/test/session-manager/migration.test.ts index 0e09acb90..3fb0c009b 100644 --- a/packages/coding-agent/test/session-manager/migration.test.ts +++ b/packages/coding-agent/test/session-manager/migration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager"; describe("migrateSessionEntries", () => { @@ -24,8 +24,8 @@ describe("migrateSessionEntries", () => { migrateSessionEntries(entries); - // Header should have version set - expect((entries[0] as any).version).toBe(2); + // Header should have version set to current + expect((entries[0] as any).version).toBe(3); // Entries should have id/parentId const msg1 = entries[1] as any; diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts index a1f28b4b9..eea3b8c0c 100644 --- a/packages/coding-agent/test/session-manager/save-entry.test.ts +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { type CustomEntry, SessionManager } from "../../src/core/session-manager"; describe("SessionManager.saveCustomEntry", () => { diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index cb9ebafb4..fb5f68aba 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { type CustomEntry, SessionManager } from "../../src/core/session-manager"; import { assistantMsg, userMsg } from "../utilities"; diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 0ed480b50..3d5438524 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,6 +1,6 @@ +import { describe, expect, it } from "bun:test"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; import { formatSkillsForPrompt, loadSkills, loadSkillsFromDir, type Skill } from "../src/core/skills"; const fixturesDir = resolve(__dirname, "fixtures/skills"); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 008cf82df..7b33bfc9d 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,7 +1,7 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { bashTool } from "../src/core/tools/bash"; import { editTool } from "../src/core/tools/edit"; import { findTool } from "../src/core/tools/find"; diff --git a/packages/coding-agent/test/truncate-to-width.test.ts b/packages/coding-agent/test/truncate-to-width.test.ts index 48e52bf7f..492135b5b 100644 --- a/packages/coding-agent/test/truncate-to-width.test.ts +++ b/packages/coding-agent/test/truncate-to-width.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "bun:test"; import { truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; -import { describe, expect, it } from "vitest"; /** * Tests for truncateToWidth behavior with Unicode characters. diff --git a/packages/coding-agent/test/worktree/collapse.test.ts b/packages/coding-agent/test/worktree/collapse.test.ts index ebc23f73f..13e745155 100644 --- a/packages/coding-agent/test/worktree/collapse.test.ts +++ b/packages/coding-agent/test/worktree/collapse.test.ts @@ -54,15 +54,15 @@ describe("collapse strategies", () => { test("rebase strategy handles divergent history", async () => { const src = await create("source"); - await Bun.write(path.join(src.path, "feature.txt"), "feature"); - await git(["add", "feature.txt"], src.path); - await git(["commit", "-m", "add feature"], src.path); - + // Make a commit on main to create divergent history const mainPath = await getRepoRoot(); await Bun.write(path.join(mainPath, "main-change.txt"), "main"); await git(["add", "main-change.txt"], mainPath); await git(["commit", "-m", "main change"], mainPath); + // Add uncommitted changes on source (collapseRebase stages and commits these) + await Bun.write(path.join(src.path, "feature.txt"), "feature"); + await collapse("source", "main", { strategy: "rebase" }); expect(await Bun.file(path.join(mainPath, "feature.txt")).exists()).toBe(true); diff --git a/packages/coding-agent/test/worktree/operations.test.ts b/packages/coding-agent/test/worktree/operations.test.ts index edfa0bdff..d719a73d0 100644 --- a/packages/coding-agent/test/worktree/operations.test.ts +++ b/packages/coding-agent/test/worktree/operations.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -42,7 +43,7 @@ describe("worktree operations", () => { test("create worktree with new branch", async () => { const wt = await create("feature-test"); expect(wt.branch).toBe("feature-test"); - expect(await Bun.file(wt.path).exists()).toBe(true); + expect(existsSync(wt.path)).toBe(true); }); test("create worktree with existing branch", async () => { @@ -73,7 +74,7 @@ describe("worktree operations", () => { test("remove worktree", async () => { const wt = await create("to-remove"); await remove("to-remove"); - expect(await Bun.file(wt.path).exists()).toBe(false); + expect(existsSync(wt.path)).toBe(false); }); test("cannot remove main worktree", async () => { diff --git a/packages/git-tool/test/safety.test.ts b/packages/git-tool/test/safety.test.ts index d3b9cb968..4fbc6dfb6 100644 --- a/packages/git-tool/test/safety.test.ts +++ b/packages/git-tool/test/safety.test.ts @@ -21,6 +21,9 @@ afterEach(() => { describe("git-tool safety", () => { it("blocks force push to protected branch", async () => { + writeFile(join(repoDir, "init.txt"), "init"); + runGit(["add", "."], repoDir); + runGit(["commit", "-m", "init"], repoDir); runGit(["branch", "-M", "main"], repoDir); const result = await checkSafety("push", { force: true }); expect(result.blocked).toBe(true); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8ad2cda7e..a979f1c11 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,12 @@ - Added `isCapsLock` helper function for detecting Caps Lock key press via Kitty protocol - Added `isCtrlY` helper function for detecting Ctrl+Y keyboard input +- Added configurable editor keybindings with typed key identifiers and action matching +- Added word-wrapped editor rendering for long lines + +### Changed + +- Settings list descriptions now wrap to the available width instead of truncating ## [3.15.1] - 2026-01-05 @@ -131,4 +137,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon ### Fixed -- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) \ No newline at end of file +- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0)) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 9af1211dd..dd1680a3e 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -22,7 +22,10 @@ import { isEnter, isEscape, isHome, + isShiftBackspace, + isShiftDelete, isShiftEnter, + isShiftSpace, isTab, } from "../keys"; import type { SymbolTheme } from "../symbols"; @@ -32,6 +35,186 @@ import { SelectList, type SelectListTheme } from "./select-list"; const segmenter = getSegmenter(); +/** + * Represents a chunk of text for word-wrap layout. + * Tracks both the text content and its position in the original line. + */ +interface TextChunk { + text: string; + startIndex: number; + endIndex: number; +} + +/** + * Split a line into word-wrapped chunks. + * Wraps at word boundaries when possible, falling back to character-level + * wrapping for words longer than the available width. + * + * @param line - The text line to wrap + * @param maxWidth - Maximum visible width per chunk + * @returns Array of chunks with text and position information + */ +function wordWrapLine(line: string, maxWidth: number): TextChunk[] { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } + + const lineWidth = visibleWidth(line); + if (lineWidth <= maxWidth) { + return [{ text: line, startIndex: 0, endIndex: line.length }]; + } + + const chunks: TextChunk[] = []; + + // Split into tokens (words and whitespace runs) + const tokens: { text: string; startIndex: number; endIndex: number; isWhitespace: boolean }[] = []; + let currentToken = ""; + let tokenStart = 0; + let inWhitespace = false; + let charIndex = 0; + + for (const seg of segmenter.segment(line)) { + const grapheme = seg.segment; + const graphemeIsWhitespace = isWhitespaceChar(grapheme); + + if (currentToken === "") { + inWhitespace = graphemeIsWhitespace; + tokenStart = charIndex; + } else if (graphemeIsWhitespace !== inWhitespace) { + // Token type changed - save current token + tokens.push({ + text: currentToken, + startIndex: tokenStart, + endIndex: charIndex, + isWhitespace: inWhitespace, + }); + currentToken = ""; + tokenStart = charIndex; + inWhitespace = graphemeIsWhitespace; + } + + currentToken += grapheme; + charIndex += grapheme.length; + } + + // Push final token + if (currentToken) { + tokens.push({ + text: currentToken, + startIndex: tokenStart, + endIndex: charIndex, + isWhitespace: inWhitespace, + }); + } + + // Build chunks using word wrapping + let currentChunk = ""; + let currentWidth = 0; + let chunkStartIndex = 0; + let atLineStart = true; // Track if we're at the start of a line (for skipping whitespace) + + for (const token of tokens) { + const tokenWidth = visibleWidth(token.text); + + // Skip leading whitespace at line start + if (atLineStart && token.isWhitespace) { + chunkStartIndex = token.endIndex; + continue; + } + atLineStart = false; + + // If this single token is wider than maxWidth, we need to break it + if (tokenWidth > maxWidth) { + // First, push any accumulated chunk + if (currentChunk) { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: token.startIndex, + }); + currentChunk = ""; + currentWidth = 0; + chunkStartIndex = token.startIndex; + } + + // Break the long token by grapheme + let tokenChunk = ""; + let tokenChunkWidth = 0; + let tokenChunkStart = token.startIndex; + let tokenCharIndex = token.startIndex; + + for (const seg of segmenter.segment(token.text)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (tokenChunkWidth + graphemeWidth > maxWidth && tokenChunk) { + chunks.push({ + text: tokenChunk, + startIndex: tokenChunkStart, + endIndex: tokenCharIndex, + }); + tokenChunk = grapheme; + tokenChunkWidth = graphemeWidth; + tokenChunkStart = tokenCharIndex; + } else { + tokenChunk += grapheme; + tokenChunkWidth += graphemeWidth; + } + tokenCharIndex += grapheme.length; + } + + // Keep remainder as start of next chunk + if (tokenChunk) { + currentChunk = tokenChunk; + currentWidth = tokenChunkWidth; + chunkStartIndex = tokenChunkStart; + } + continue; + } + + // Check if adding this token would exceed width + if (currentWidth + tokenWidth > maxWidth) { + // Push current chunk (trimming trailing whitespace for display) + const trimmedChunk = currentChunk.trimEnd(); + if (trimmedChunk || chunks.length === 0) { + chunks.push({ + text: trimmedChunk, + startIndex: chunkStartIndex, + endIndex: chunkStartIndex + currentChunk.length, + }); + } + + // Start new line - skip leading whitespace + atLineStart = true; + if (token.isWhitespace) { + currentChunk = ""; + currentWidth = 0; + chunkStartIndex = token.endIndex; + } else { + currentChunk = token.text; + currentWidth = tokenWidth; + chunkStartIndex = token.startIndex; + atLineStart = false; + } + } else { + // Add token to current chunk + currentChunk += token.text; + currentWidth += tokenWidth; + } + } + + // Push final chunk + if (currentChunk) { + chunks.push({ + text: currentChunk, + startIndex: chunkStartIndex, + endIndex: line.length, + }); + } + + return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }]; +} + interface EditorState { lines: string[]; cursorLine: number; @@ -92,6 +275,7 @@ export class Editor implements Component { private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. public onSubmit?: (text: string) => void; + public onAltEnter?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; @@ -493,13 +677,20 @@ export class Editor implements Component { else if (isCtrlE(data)) { this.moveToLineEnd(); } + // Alt+Enter - special handler if callback exists, otherwise new line + else if (isAltEnter(data)) { + if (this.onAltEnter) { + this.onAltEnter(this.getText()); + } else { + this.addNewLine(); + } + } // New line shortcuts (but not plain LF/CR which should be submit) else if ( (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers data === "\x1b\r" || // Option+Enter in some terminals (legacy) data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format) isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits) - isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits) (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping data === "\\\r" // Shift+Enter in VS Code terminal @@ -543,8 +734,8 @@ export class Editor implements Component { this.onSubmit(result); } } - // Backspace - else if (isBackspace(data)) { + // Backspace (including Shift+Backspace) + else if (isBackspace(data) || isShiftBackspace(data)) { this.handleBackspace(); } // Line navigation shortcuts (Home/End keys) @@ -553,8 +744,8 @@ export class Editor implements Component { } else if (isEnd(data)) { this.moveToLineEnd(); } - // Forward delete (Fn+Backspace or Delete key) - else if (isDelete(data)) { + // Forward delete (Fn+Backspace or Delete key, including Shift+Delete) + else if (isDelete(data) || isShiftDelete(data)) { this.handleForwardDelete(); } // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) @@ -589,8 +780,8 @@ export class Editor implements Component { // Left this.moveCursor(0, -1); } - // Shift+Space via Kitty protocol (sends \x1b[32;2u instead of plain space) - else if (data === "\x1b[32;2u" || data.match(/^\x1b\[32;\d+u$/)) { + // Shift+Space - insert regular space (Kitty protocol sends escape sequence) + else if (isShiftSpace(data)) { this.insertCharacter(" "); } // Regular characters (printable characters and unicode, but not control characters) @@ -633,42 +824,8 @@ export class Editor implements Component { }); } } else { - // Line needs wrapping - use grapheme-aware chunking - const chunks: { text: string; startIndex: number; endIndex: number }[] = []; - let currentChunk = ""; - let currentWidth = 0; - let chunkStartIndex = 0; - let currentIndex = 0; - - for (const seg of segmenter.segment(line)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > contentWidth && currentChunk !== "") { - // Start a new chunk - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: currentIndex, - }); - currentChunk = grapheme; - currentWidth = graphemeWidth; - chunkStartIndex = currentIndex; - } else { - currentChunk += grapheme; - currentWidth += graphemeWidth; - } - currentIndex += grapheme.length; - } - - // Push the last chunk - if (currentChunk !== "") { - chunks.push({ - text: currentChunk, - startIndex: chunkStartIndex, - endIndex: currentIndex, - }); - } + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, contentWidth); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; @@ -676,17 +833,37 @@ export class Editor implements Component { const cursorPos = this.state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; - // For non-last chunks, cursor at endIndex belongs to the next chunk - const hasCursorInChunk = - isCurrentLine && - cursorPos >= chunk.startIndex && - (isLastChunk ? cursorPos <= chunk.endIndex : cursorPos < chunk.endIndex); + + // Determine if cursor is in this chunk + // For word-wrapped chunks, we need to handle the case where + // cursor might be in trimmed whitespace at end of chunk + let hasCursorInChunk = false; + let adjustedCursorPos = 0; + + if (isCurrentLine) { + if (isLastChunk) { + // Last chunk: cursor belongs here if >= startIndex + hasCursorInChunk = cursorPos >= chunk.startIndex; + adjustedCursorPos = cursorPos - chunk.startIndex; + } else { + // Non-last chunk: cursor belongs here if in range [startIndex, endIndex) + // But we need to handle the visual position in the trimmed text + hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex; + if (hasCursorInChunk) { + adjustedCursorPos = cursorPos - chunk.startIndex; + // Clamp to text length (in case cursor was in trimmed whitespace) + if (adjustedCursorPos > chunk.text.length) { + adjustedCursorPos = chunk.text.length; + } + } + } + } if (hasCursorInChunk) { layoutLines.push({ text: chunk.text, hasCursor: true, - cursorPos: cursorPos - chunk.startIndex, + cursorPos: adjustedCursorPos, }); } else { layoutLines.push({ @@ -767,7 +944,7 @@ export class Editor implements Component { } } // Also auto-trigger when typing letters in a slash command context - else if (/[a-zA-Z0-9]/.test(char)) { + else if (/[a-zA-Z0-9.\-_]/.test(char)) { const currentLine = this.state.lines[this.state.cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); // Check if we're in a slash command (with or without space for arguments) @@ -1103,36 +1280,13 @@ export class Editor implements Component { } else if (lineVisWidth <= width) { visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); } else { - // Line needs wrapping - use grapheme-aware chunking - let currentWidth = 0; - let chunkStartIndex = 0; - let currentIndex = 0; - - for (const seg of segmenter.segment(line)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > width && currentIndex > chunkStartIndex) { - // Start a new chunk - visualLines.push({ - logicalLine: i, - startCol: chunkStartIndex, - length: currentIndex - chunkStartIndex, - }); - chunkStartIndex = currentIndex; - currentWidth = graphemeWidth; - } else { - currentWidth += graphemeWidth; - } - currentIndex += grapheme.length; - } - - // Push the last chunk - if (currentIndex > chunkStartIndex) { + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, width); + for (const chunk of chunks) { visualLines.push({ logicalLine: i, - startCol: chunkStartIndex, - length: currentIndex - chunkStartIndex, + startCol: chunk.startIndex, + length: chunk.endIndex - chunk.startIndex, }); } } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 72b22a79e..501dfade3 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,6 @@ import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys"; import type { Component } from "../tui"; -import { truncateToWidth, visibleWidth } from "../utils"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; export interface SettingItem { /** Unique identifier for this setting */ @@ -123,7 +123,10 @@ export class SettingsList implements Component { const selectedItem = this.items[this.selectedIndex]; if (selectedItem?.description) { lines.push(""); - lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`)); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); + for (const line of wrappedDesc) { + lines.push(this.theme.description(` ${line}`)); + } } // Add hint diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 0d8433df1..743719dc3 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -21,6 +21,15 @@ export { Spacer } from "./components/spacer"; export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar"; export { Text } from "./components/text"; export { TruncatedText } from "./components/truncated-text"; +// Keybindings +export { + DEFAULT_EDITOR_KEYBINDINGS, + type EditorAction, + type EditorKeybindingsConfig, + EditorKeybindingsManager, + getEditorKeybindings, + setEditorKeybindings, +} from "./keybindings"; // Kitty keyboard protocol helpers export { isAltBackspace, @@ -55,13 +64,19 @@ export { isEnter, isEscape, isHome, + isShiftBackspace, isShiftCtrlD, isShiftCtrlO, isShiftCtrlP, + isShiftDelete, isShiftEnter, + isShiftSpace, isShiftTab, isTab, - Keys, + Key, + type KeyId, + matchesKey, + parseKey, } from "./keys"; export type { BoxSymbols, SymbolTheme } from "./symbols"; // Terminal interface and implementations diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts new file mode 100644 index 000000000..af38dd0b0 --- /dev/null +++ b/packages/tui/src/keybindings.ts @@ -0,0 +1,143 @@ +import { type KeyId, matchesKey } from "./keys"; + +/** + * Editor actions that can be bound to keys. + */ +export type EditorAction = + // Cursor movement + | "cursorUp" + | "cursorDown" + | "cursorLeft" + | "cursorRight" + | "cursorWordLeft" + | "cursorWordRight" + | "cursorLineStart" + | "cursorLineEnd" + // Deletion + | "deleteCharBackward" + | "deleteCharForward" + | "deleteWordBackward" + | "deleteToLineStart" + | "deleteToLineEnd" + // Text input + | "newLine" + | "submit" + | "tab" + // Selection/autocomplete + | "selectUp" + | "selectDown" + | "selectConfirm" + | "selectCancel" + // Clipboard + | "copy"; + +// Re-export KeyId from keys.ts +export type { KeyId }; + +/** + * Editor keybindings configuration. + */ +export type EditorKeybindingsConfig = { + [K in EditorAction]?: KeyId | KeyId[]; +}; + +/** + * Default editor keybindings. + */ +export const DEFAULT_EDITOR_KEYBINDINGS: Required = { + // Cursor movement + cursorUp: "up", + cursorDown: "down", + cursorLeft: "left", + cursorRight: "right", + cursorWordLeft: ["alt+left", "ctrl+left"], + cursorWordRight: ["alt+right", "ctrl+right"], + cursorLineStart: ["home", "ctrl+a"], + cursorLineEnd: ["end", "ctrl+e"], + // Deletion + deleteCharBackward: "backspace", + deleteCharForward: "delete", + deleteWordBackward: ["ctrl+w", "alt+backspace"], + deleteToLineStart: "ctrl+u", + deleteToLineEnd: "ctrl+k", + // Text input + newLine: ["shift+enter", "alt+enter"], + submit: "enter", + tab: "tab", + // Selection/autocomplete + selectUp: "up", + selectDown: "down", + selectConfirm: "enter", + selectCancel: ["escape", "ctrl+c"], + // Clipboard + copy: "ctrl+c", +}; + +/** + * Manages keybindings for the editor. + */ +export class EditorKeybindingsManager { + private actionToKeys: Map; + + constructor(config: EditorKeybindingsConfig = {}) { + this.actionToKeys = new Map(); + this.buildMaps(config); + } + + private buildMaps(config: EditorKeybindingsConfig): void { + this.actionToKeys.clear(); + + // Start with defaults + for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) { + const keyArray = Array.isArray(keys) ? keys : [keys]; + this.actionToKeys.set(action as EditorAction, [...keyArray]); + } + + // Override with user config + for (const [action, keys] of Object.entries(config)) { + if (keys === undefined) continue; + const keyArray = Array.isArray(keys) ? keys : [keys]; + this.actionToKeys.set(action as EditorAction, keyArray); + } + } + + /** + * Check if input matches a specific action. + */ + matches(data: string, action: EditorAction): boolean { + const keys = this.actionToKeys.get(action); + if (!keys) return false; + for (const key of keys) { + if (matchesKey(data, key)) return true; + } + return false; + } + + /** + * Get keys bound to an action. + */ + getKeys(action: EditorAction): KeyId[] { + return this.actionToKeys.get(action) ?? []; + } + + /** + * Update configuration. + */ + setConfig(config: EditorKeybindingsConfig): void { + this.buildMaps(config); + } +} + +// Global instance +let globalEditorKeybindings: EditorKeybindingsManager | null = null; + +export function getEditorKeybindings(): EditorKeybindingsManager { + if (!globalEditorKeybindings) { + globalEditorKeybindings = new EditorKeybindingsManager(); + } + return globalEditorKeybindings; +} + +export function setEditorKeybindings(manager: EditorKeybindingsManager): void { + globalEditorKeybindings = manager; +} diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 355cbe8df..c872e54d5 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -1,91 +1,263 @@ /** - * Kitty keyboard protocol key sequence helpers. - * - * The Kitty keyboard protocol sends enhanced escape sequences in the format: - * \x1b[;u - * - * Modifier bits (before adding 1 for transmission): - * - Shift: 1 (value 2) - * - Alt: 2 (value 3) - * - Ctrl: 4 (value 5) - * - Super: 8 (value 9) - * - Hyper: 16 - * - Meta: 32 - * - Caps_Lock: 64 - * - Num_Lock: 128 + * Keyboard input handling for terminal applications. * + * Supports both legacy terminal sequences and Kitty keyboard protocol. * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ * - * NOTE: Some terminals (e.g., Ghostty on Linux) include lock key states - * (Caps Lock, Num Lock) in the modifier field. We mask these out when - * checking for key combinations since they shouldn't affect behavior. + * Symbol keys are also supported, however some ctrl+symbol combos + * overlap with ASCII codes, e.g. ctrl+[ = ESC. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys + * Those can still be * used for ctrl+shift combos + * + * API: + * - matchesKey(data, keyId) - Check if input matches a key identifier + * - parseKey(data) - Parse input and return the key identifier + * - Key - Helper object for creating typed key identifiers */ -// Common codepoints -const CODEPOINTS = { - // Letters (lowercase ASCII) - a: 97, - c: 99, - d: 100, - e: 101, - g: 103, - k: 107, - l: 108, - o: 111, - p: 112, - t: 116, - u: 117, - v: 118, - w: 119, - y: 121, - z: 122, +// ============================================================================= +// Type-Safe Key Identifiers +// ============================================================================= +type Letter = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type SymbolKey = + | "`" + | "-" + | "=" + | "[" + | "]" + | "\\" + | ";" + | "'" + | "," + | "." + | "/" + | "!" + | "@" + | "#" + | "$" + | "%" + | "^" + | "&" + | "*" + | "(" + | ")" + | "_" + | "+" + | "|" + | "~" + | "{" + | "}" + | ":" + | "<" + | ">" + | "?"; + +type SpecialKey = + | "escape" + | "esc" + | "enter" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "home" + | "end" + | "up" + | "down" + | "left" + | "right"; + +type BaseKey = Letter | SymbolKey | SpecialKey; + +/** + * Union type of all valid key identifiers. + * Provides autocomplete and catches typos at compile time. + */ +export type KeyId = + | BaseKey + | `ctrl+${BaseKey}` + | `shift+${BaseKey}` + | `alt+${BaseKey}` + | `ctrl+shift+${BaseKey}` + | `shift+ctrl+${BaseKey}` + | `ctrl+alt+${BaseKey}` + | `alt+ctrl+${BaseKey}` + | `shift+alt+${BaseKey}` + | `alt+shift+${BaseKey}` + | `ctrl+shift+alt+${BaseKey}` + | `ctrl+alt+shift+${BaseKey}` + | `shift+ctrl+alt+${BaseKey}` + | `shift+alt+ctrl+${BaseKey}` + | `alt+ctrl+shift+${BaseKey}` + | `alt+shift+ctrl+${BaseKey}`; + +/** + * Helper object for creating typed key identifiers with autocomplete. + * + * Usage: + * - Key.escape, Key.enter, Key.tab, etc. for special keys + * - Key.backtick, Key.comma, Key.period, etc. for symbol keys + * - Key.ctrl("c"), Key.alt("x") for single modifier + * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers + */ +export const Key = { // Special keys - escape: 27, - tab: 9, - enter: 13, - backspace: 127, + escape: "escape" as const, + esc: "esc" as const, + enter: "enter" as const, + return: "return" as const, + tab: "tab" as const, + space: "space" as const, + backspace: "backspace" as const, + delete: "delete" as const, + home: "home" as const, + end: "end" as const, + up: "up" as const, + down: "down" as const, + left: "left" as const, + right: "right" as const, + + // Symbol keys + backtick: "`" as const, + hyphen: "-" as const, + equals: "=" as const, + leftbracket: "[" as const, + rightbracket: "]" as const, + backslash: "\\" as const, + semicolon: ";" as const, + quote: "'" as const, + comma: "," as const, + period: "." as const, + slash: "/" as const, + exclamation: "!" as const, + at: "@" as const, + hash: "#" as const, + dollar: "$" as const, + percent: "%" as const, + caret: "^" as const, + ampersand: "&" as const, + asterisk: "*" as const, + leftparen: "(" as const, + rightparen: ")" as const, + underscore: "_" as const, + plus: "+" as const, + pipe: "|" as const, + tilde: "~" as const, + leftbrace: "{" as const, + rightbrace: "}" as const, + colon: ":" as const, + lessthan: "<" as const, + greaterthan: ">" as const, + question: "?" as const, + + // Single modifiers + ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, + shift: (key: K): `shift+${K}` => `shift+${key}`, + alt: (key: K): `alt+${K}` => `alt+${key}`, + + // Combined modifiers + ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, + shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, + ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, + altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, + shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, + altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + + // Triple modifiers + ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, } as const; -// Kitty protocol codepoint for Caps Lock key -const CAPS_LOCK_CODEPOINT = 57358; +// ============================================================================= +// Constants +// ============================================================================= -// Lock key bits to ignore when matching (Caps Lock + Num Lock) -const LOCK_MASK = 64 + 128; // 192 +const SYMBOL_KEYS = new Set([ + "`", + "-", + "=", + "[", + "]", + "\\", + ";", + "'", + ",", + ".", + "/", + "!", + "@", + "#", + "$", + "%", + "^", + "&", + "*", + "(", + ")", + "_", + "+", + "|", + "~", + "{", + "}", + ":", + "<", + ">", + "?", +]); -// Modifier bits (before adding 1) const MODIFIERS = { shift: 1, alt: 2, ctrl: 4, - super: 8, } as const; -/** - * Build a Kitty keyboard protocol sequence for a key with modifier. - */ -function kittySequence(codepoint: number, modifier: number): string { - return `\x1b[${codepoint};${modifier + 1}u`; -} +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock -/** - * Parsed Kitty keyboard protocol sequence. - */ -interface ParsedKittySequence { - codepoint: number; - modifier: number; // Actual modifier bits (after subtracting 1) -} +const CODEPOINTS = { + escape: 27, + tab: 9, + enter: 13, + space: 32, + backspace: 127, + kpEnter: 57414, // Numpad Enter (Kitty protocol) +} as const; + +const ARROW_CODEPOINTS = { + up: -1, + down: -2, + right: -3, + left: -4, +} as const; -/** - * Parse a Kitty keyboard protocol sequence. - * Handles formats: - * - \x1b[u (no modifier) - * - \x1b[;u (with modifier) - * - \x1b[1;A/B/C/D (arrow keys with modifier) - * - * Returns null if not a valid Kitty sequence. - */ -// Virtual codepoints for functional keys (negative to avoid conflicts) const FUNCTIONAL_CODEPOINTS = { delete: -10, insert: -11, @@ -95,8 +267,17 @@ const FUNCTIONAL_CODEPOINTS = { end: -15, } as const; +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +interface ParsedKittySequence { + codepoint: number; + modifier: number; +} + function parseKittySequence(data: string): ParsedKittySequence | null { - // Match CSI u format: \x1b[u or \x1b[;u + // CSI u format: \x1b[u or \x1b[;u const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/); if (csiUMatch) { const codepoint = parseInt(csiUMatch[1]!, 10); @@ -104,30 +285,26 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return { codepoint, modifier: modValue - 1 }; } - // Match arrow keys with modifier: \x1b[1;A/B/C/D + // Arrow keys with modifier: \x1b[1;A/B/C/D const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/); if (arrowMatch) { const modValue = parseInt(arrowMatch[1]!, 10); - // Map arrow letters to virtual codepoints for easier matching const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - const codepoint = arrowCodes[arrowMatch[2]!]!; - return { codepoint, modifier: modValue - 1 }; + return { codepoint: arrowCodes[arrowMatch[2]!]!, modifier: modValue - 1 }; } - // Match functional keys with ~ terminator: \x1b[~ or \x1b[;~ - // DELETE=3, INSERT=2, PAGEUP=5, PAGEDOWN=6, etc. + // Functional keys: \x1b[~ or \x1b[;~ const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/); if (funcMatch) { const keyNum = parseInt(funcMatch[1]!, 10); const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; - // Map functional key numbers to virtual codepoints const funcCodes: Record = { 2: FUNCTIONAL_CODEPOINTS.insert, 3: FUNCTIONAL_CODEPOINTS.delete, 5: FUNCTIONAL_CODEPOINTS.pageUp, 6: FUNCTIONAL_CODEPOINTS.pageDown, - 7: FUNCTIONAL_CODEPOINTS.home, // Alternative home - 8: FUNCTIONAL_CODEPOINTS.end, // Alternative end + 7: FUNCTIONAL_CODEPOINTS.home, + 8: FUNCTIONAL_CODEPOINTS.end, }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { @@ -135,7 +312,7 @@ function parseKittySequence(data: string): ParsedKittySequence | null { } } - // Match Home/End with modifier: \x1b[1;H/F + // Home/End with modifier: \x1b[1;H/F const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/); if (homeEndMatch) { const modValue = parseInt(homeEndMatch[1]!, 10); @@ -146,437 +323,462 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return null; } -/** - * Check if a Kitty sequence matches the expected codepoint and modifier, - * ignoring lock key bits (Caps Lock, Num Lock). - */ function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { const parsed = parseKittySequence(data); if (!parsed) return false; - - // Mask out lock bits from both sides for comparison const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - return parsed.codepoint === expectedCodepoint && actualMod === expectedMod; } -// Pre-built sequences for common key combinations -export const Keys = { - // Ctrl+ combinations - CTRL_A: kittySequence(CODEPOINTS.a, MODIFIERS.ctrl), - CTRL_C: kittySequence(CODEPOINTS.c, MODIFIERS.ctrl), - CTRL_D: kittySequence(CODEPOINTS.d, MODIFIERS.ctrl), - CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), - CTRL_G: kittySequence(CODEPOINTS.g, MODIFIERS.ctrl), - CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), - CTRL_L: kittySequence(CODEPOINTS.l, MODIFIERS.ctrl), - CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), - CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), - CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), - CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), - CTRL_V: kittySequence(CODEPOINTS.v, MODIFIERS.ctrl), - CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), - CTRL_Y: kittySequence(CODEPOINTS.y, MODIFIERS.ctrl), - CTRL_Z: kittySequence(CODEPOINTS.z, MODIFIERS.ctrl), +// ============================================================================= +// Generic Key Matching +// ============================================================================= - // Enter combinations - SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift), - ALT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.alt), - CTRL_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.ctrl), +function rawCtrlChar(letter: string): string { + const code = letter.toLowerCase().charCodeAt(0) - 96; + return String.fromCharCode(code); +} - // Tab combinations - SHIFT_TAB: kittySequence(CODEPOINTS.tab, MODIFIERS.shift), - - // Backspace combinations - ALT_BACKSPACE: kittySequence(CODEPOINTS.backspace, MODIFIERS.alt), -} as const; - -/** - * Check if input matches a Kitty protocol Ctrl+ sequence. - * Ignores lock key bits (Caps Lock, Num Lock). - * @param data - The input data to check - * @param key - Single lowercase letter (e.g., 'c' for Ctrl+C) - */ -export function isKittyCtrl(data: string, key: string): boolean { - if (key.length !== 1) return false; - const codepoint = key.charCodeAt(0); - // Check exact match first (fast path) - if (data === kittySequence(codepoint, MODIFIERS.ctrl)) return true; - // Check with lock bits masked out - return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); +function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { + const parts = keyId.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + return { + key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + }; } /** - * Check if input matches a Kitty protocol key sequence with specific modifier. - * Ignores lock key bits (Caps Lock, Num Lock). - * @param data - The input data to check - * @param codepoint - ASCII codepoint of the key - * @param modifier - Modifier value (use MODIFIERS constants) + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" + * + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p") + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ -export function isKittyKey(data: string, codepoint: number, modifier: number): boolean { - // Check exact match first (fast path) - if (data === kittySequence(codepoint, modifier)) return true; - // Check with lock bits masked out - return matchesKittySequence(data, codepoint, modifier); +export function matchesKey(data: string, keyId: KeyId): boolean { + const parsed = parseKeyId(keyId); + if (!parsed) return false; + + const { key, ctrl, shift, alt } = parsed; + let modifier = 0; + if (shift) modifier |= MODIFIERS.shift; + if (alt) modifier |= MODIFIERS.alt; + if (ctrl) modifier |= MODIFIERS.ctrl; + + switch (key) { + case "escape": + case "esc": + if (modifier !== 0) return false; + return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); + + case "space": + if (modifier === 0) { + return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0); + } + return matchesKittySequence(data, CODEPOINTS.space, modifier); + + case "tab": + if (shift && !ctrl && !alt) { + return data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift); + } + if (modifier === 0) { + return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); + } + return matchesKittySequence(data, CODEPOINTS.tab, modifier); + + case "enter": + case "return": + if (shift && !ctrl && !alt) { + return ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) + ); + } + if (alt && !ctrl && !shift) { + return ( + data === "\x1b\r" || + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) + ); + } + if (modifier === 0) { + return ( + data === "\r" || + data === "\x1bOM" || // SS3 M (numpad enter in some terminals) + matchesKittySequence(data, CODEPOINTS.enter, 0) || + matchesKittySequence(data, CODEPOINTS.kpEnter, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.enter, modifier) || + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) + ); + + case "backspace": + if (alt && !ctrl && !shift) { + return data === "\x1b\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt); + } + if (modifier === 0) { + return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); + } + return matchesKittySequence(data, CODEPOINTS.backspace, modifier); + + case "delete": + if (modifier === 0) { + return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); + + case "home": + if (modifier === 0) { + return ( + data === "\x1b[H" || + data === "\x1b[1~" || + data === "\x1b[7~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); + + case "end": + if (modifier === 0) { + return ( + data === "\x1b[F" || + data === "\x1b[4~" || + data === "\x1b[8~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); + + case "up": + if (modifier === 0) { + return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); + + case "down": + if (modifier === 0) { + return data === "\x1b[B" || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); + + case "left": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3D" || + data === "\x1bb" || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return data === "\x1b[1;5D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl); + } + if (modifier === 0) { + return data === "\x1b[D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); + + case "right": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3C" || + data === "\x1bf" || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl); + } + if (modifier === 0) { + return data === "\x1b[C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); + } + + // Handle single letter keys (a-z) and some symbols + if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) { + const codepoint = key.charCodeAt(0); + + if (ctrl && !shift && !alt) { + const raw = rawCtrlChar(key); + if (data === raw) return true; + if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); + } + + if (ctrl && shift && !alt) { + return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); + } + + if (shift && !ctrl && !alt) { + // Legacy: shift+letter produces uppercase + if (data === key.toUpperCase()) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.shift); + } + + if (modifier !== 0) { + return matchesKittySequence(data, codepoint, modifier); + } + + return data === key; + } + + return false; } /** - * Check if input matches a Caps Lock key press (Kitty protocol). - * Ignores lock key bits in modifiers. + * Parse input data and return the key identifier if recognized. + * + * @param data - Raw input data from terminal + * @returns Key identifier string (e.g., "ctrl+c") or undefined + */ +export function parseKey(data: string): string | undefined { + const kitty = parseKittySequence(data); + if (kitty) { + const { codepoint, modifier } = kitty; + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + + let keyName: string | undefined; + if (codepoint === CODEPOINTS.escape) keyName = "escape"; + else if (codepoint === CODEPOINTS.tab) keyName = "tab"; + else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (codepoint === CODEPOINTS.space) keyName = "space"; + else if (codepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint); + else if (SYMBOL_KEYS.has(String.fromCharCode(codepoint))) keyName = String.fromCharCode(codepoint); + + if (keyName) { + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; + } + } + + // Legacy sequences + if (data === "\x1b") return "escape"; + if (data === "\t") return "tab"; + if (data === "\r" || data === "\x1bOM") return "enter"; + if (data === " ") return "space"; + if (data === "\x7f" || data === "\x08") return "backspace"; + if (data === "\x1b[Z") return "shift+tab"; + if (data === "\x1b\r") return "alt+enter"; + if (data === "\x1b\x7f") return "alt+backspace"; + if (data === "\x1b[A") return "up"; + if (data === "\x1b[B") return "down"; + if (data === "\x1b[C") return "right"; + if (data === "\x1b[D") return "left"; + if (data === "\x1b[H") return "home"; + if (data === "\x1b[F") return "end"; + if (data === "\x1b[3~") return "delete"; + + // Raw Ctrl+letter + if (data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 1 && code <= 26) { + return `ctrl+${String.fromCharCode(code + 96)}`; + } + if (code >= 32 && code <= 126) { + return data; + } + } + + return undefined; +} + +// ============================================================================= +// Legacy helper wrappers (for compatibility) +// ============================================================================= + +export function isArrowUp(data: string): boolean { + return matchesKey(data, "up"); +} + +export function isArrowDown(data: string): boolean { + return matchesKey(data, "down"); +} + +export function isArrowLeft(data: string): boolean { + return matchesKey(data, "left"); +} + +export function isArrowRight(data: string): boolean { + return matchesKey(data, "right"); +} + +export function isEscape(data: string): boolean { + return matchesKey(data, "escape") || matchesKey(data, "esc"); +} + +export function isEnter(data: string): boolean { + return matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n"; +} + +export function isTab(data: string): boolean { + return matchesKey(data, "tab"); +} + +export function isShiftTab(data: string): boolean { + return matchesKey(data, "shift+tab"); +} + +export function isShiftEnter(data: string): boolean { + return matchesKey(data, "shift+enter"); +} + +export function isAltEnter(data: string): boolean { + return matchesKey(data, "alt+enter"); +} + +export function isAltBackspace(data: string): boolean { + return matchesKey(data, "alt+backspace"); +} + +export function isBackspace(data: string): boolean { + return matchesKey(data, "backspace"); +} + +export function isDelete(data: string): boolean { + return matchesKey(data, "delete"); +} + +export function isHome(data: string): boolean { + return matchesKey(data, "home"); +} + +export function isEnd(data: string): boolean { + return matchesKey(data, "end"); +} + +export function isCtrlA(data: string): boolean { + return matchesKey(data, "ctrl+a"); +} + +export function isCtrlC(data: string): boolean { + return matchesKey(data, "ctrl+c"); +} + +export function isCtrlD(data: string): boolean { + return matchesKey(data, "ctrl+d"); +} + +export function isCtrlE(data: string): boolean { + return matchesKey(data, "ctrl+e"); +} + +export function isCtrlG(data: string): boolean { + return matchesKey(data, "ctrl+g"); +} + +export function isCtrlK(data: string): boolean { + return matchesKey(data, "ctrl+k"); +} + +export function isCtrlL(data: string): boolean { + return matchesKey(data, "ctrl+l"); +} + +export function isCtrlO(data: string): boolean { + return matchesKey(data, "ctrl+o"); +} + +export function isCtrlP(data: string): boolean { + return matchesKey(data, "ctrl+p"); +} + +export function isCtrlT(data: string): boolean { + return matchesKey(data, "ctrl+t"); +} + +export function isCtrlU(data: string): boolean { + return matchesKey(data, "ctrl+u"); +} + +export function isCtrlV(data: string): boolean { + return matchesKey(data, "ctrl+v"); +} + +export function isCtrlW(data: string): boolean { + return matchesKey(data, "ctrl+w"); +} + +export function isCtrlY(data: string): boolean { + return matchesKey(data, "ctrl+y"); +} + +export function isCtrlZ(data: string): boolean { + return matchesKey(data, "ctrl+z"); +} + +export function isCtrlLeft(data: string): boolean { + return matchesKey(data, "ctrl+left"); +} + +export function isCtrlRight(data: string): boolean { + return matchesKey(data, "ctrl+right"); +} + +export function isAltLeft(data: string): boolean { + return matchesKey(data, "alt+left"); +} + +export function isAltRight(data: string): boolean { + return matchesKey(data, "alt+right"); +} + +export function isShiftCtrlD(data: string): boolean { + return matchesKey(data, "shift+ctrl+d") || matchesKey(data, "ctrl+shift+d"); +} + +export function isShiftCtrlO(data: string): boolean { + return matchesKey(data, "shift+ctrl+o") || matchesKey(data, "ctrl+shift+o"); +} + +export function isShiftCtrlP(data: string): boolean { + return matchesKey(data, "shift+ctrl+p") || matchesKey(data, "ctrl+shift+p"); +} + +export function isShiftBackspace(data: string): boolean { + return matchesKey(data, "shift+backspace"); +} + +export function isShiftDelete(data: string): boolean { + return matchesKey(data, "shift+delete"); +} + +export function isShiftSpace(data: string): boolean { + return matchesKey(data, "shift+space"); +} + +/** + * Check if input indicates Caps Lock state change. + * Kitty protocol reports Caps Lock via modifier bit 64. */ export function isCapsLock(data: string): boolean { - return isKittyKey(data, CAPS_LOCK_CODEPOINT, 0); -} - -// Raw control character codes -const RAW = { - CTRL_A: "\x01", - CTRL_C: "\x03", - CTRL_D: "\x04", - CTRL_E: "\x05", - CTRL_G: "\x07", - CTRL_K: "\x0b", - CTRL_L: "\x0c", - CTRL_O: "\x0f", - CTRL_P: "\x10", - CTRL_T: "\x14", - CTRL_U: "\x15", - CTRL_V: "\x16", - CTRL_W: "\x17", - CTRL_Y: "\x19", - CTRL_Z: "\x1a", - ALT_BACKSPACE: "\x1b\x7f", - SHIFT_TAB: "\x1b[Z", -} as const; - -/** - * Check if input matches Ctrl+A (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlA(data: string): boolean { - return data === RAW.CTRL_A || data === Keys.CTRL_A || matchesKittySequence(data, CODEPOINTS.a, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+C (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlC(data: string): boolean { - return data === RAW.CTRL_C || data === Keys.CTRL_C || matchesKittySequence(data, CODEPOINTS.c, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+D (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlD(data: string): boolean { - return data === RAW.CTRL_D || data === Keys.CTRL_D || matchesKittySequence(data, CODEPOINTS.d, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+E (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlE(data: string): boolean { - return data === RAW.CTRL_E || data === Keys.CTRL_E || matchesKittySequence(data, CODEPOINTS.e, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+G (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlG(data: string): boolean { - return data === RAW.CTRL_G || data === Keys.CTRL_G || matchesKittySequence(data, CODEPOINTS.g, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+K (raw byte or Kitty protocol). - * Ignores lock key bits. - * Also checks if first byte is 0x0b for compatibility with terminals - * that may send trailing bytes. - */ -export function isCtrlK(data: string): boolean { - return ( - data === RAW.CTRL_K || - (data.length > 0 && data.charCodeAt(0) === 0x0b) || - data === Keys.CTRL_K || - matchesKittySequence(data, CODEPOINTS.k, MODIFIERS.ctrl) - ); -} - -/** - * Check if input matches Ctrl+L (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlL(data: string): boolean { - return data === RAW.CTRL_L || data === Keys.CTRL_L || matchesKittySequence(data, CODEPOINTS.l, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+O (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlO(data: string): boolean { - return data === RAW.CTRL_O || data === Keys.CTRL_O || matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+O (Kitty protocol only). - * Ignores lock key bits. - */ -export function isShiftCtrlO(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+P (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlP(data: string): boolean { - return data === RAW.CTRL_P || data === Keys.CTRL_P || matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+P (Kitty protocol only). - * Ignores lock key bits. - */ -export function isShiftCtrlP(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+D (Kitty protocol only, for debug). - * Ignores lock key bits. - */ -export function isShiftCtrlD(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.d, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+T (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlT(data: string): boolean { - return data === RAW.CTRL_T || data === Keys.CTRL_T || matchesKittySequence(data, CODEPOINTS.t, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+U (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlU(data: string): boolean { - return data === RAW.CTRL_U || data === Keys.CTRL_U || matchesKittySequence(data, CODEPOINTS.u, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+V (raw byte or Kitty protocol). - * Ignores lock key bits. - * Note: In most terminals, Ctrl+V triggers paste which sends bracketed paste markers, - * but raw mode can intercept the key before the terminal processes it. - */ -export function isCtrlV(data: string): boolean { - return data === RAW.CTRL_V || data === Keys.CTRL_V || matchesKittySequence(data, CODEPOINTS.v, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+W (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlW(data: string): boolean { - return data === RAW.CTRL_W || data === Keys.CTRL_W || matchesKittySequence(data, CODEPOINTS.w, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+Y (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlY(data: string): boolean { - return data === RAW.CTRL_Y || data === Keys.CTRL_Y || matchesKittySequence(data, CODEPOINTS.y, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+Z (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlZ(data: string): boolean { - return data === RAW.CTRL_Z || data === Keys.CTRL_Z || matchesKittySequence(data, CODEPOINTS.z, MODIFIERS.ctrl); -} - -/** - * Check if input matches Alt+Backspace (legacy or Kitty protocol). - * Ignores lock key bits. - */ -export function isAltBackspace(data: string): boolean { - return ( - data === RAW.ALT_BACKSPACE || - data === Keys.ALT_BACKSPACE || - matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) - ); -} - -/** - * Check if input matches Shift+Tab (legacy or Kitty protocol). - * Ignores lock key bits. - */ -export function isShiftTab(data: string): boolean { - return ( - data === RAW.SHIFT_TAB || data === Keys.SHIFT_TAB || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) - ); -} - -/** - * Check if input matches the Escape key (raw byte or Kitty protocol). - * Raw: \x1b (single byte) - * Kitty: \x1b[27u (codepoint 27 = escape) - * Ignores lock key bits. - */ -export function isEscape(data: string): boolean { - return data === "\x1b" || data === `\x1b[${CODEPOINTS.escape}u` || matchesKittySequence(data, CODEPOINTS.escape, 0); -} - -// Arrow key virtual codepoints (negative to avoid conflicts with real codepoints) -const ARROW_CODEPOINTS = { - up: -1, - down: -2, - right: -3, - left: -4, -} as const; - -/** - * Check if input matches Arrow Up key. - * Handles both legacy (\x1b[A) and Kitty protocol with modifiers. - */ -export function isArrowUp(data: string): boolean { - return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0); -} - -/** - * Check if input matches Arrow Down key. - * Handles both legacy (\x1b[B) and Kitty protocol with modifiers. - */ -export function isArrowDown(data: string): boolean { - return data === "\x1b[B" || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0); -} - -/** - * Check if input matches Arrow Right key. - * Handles both legacy (\x1b[C) and Kitty protocol with modifiers. - */ -export function isArrowRight(data: string): boolean { - return data === "\x1b[C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0); -} - -/** - * Check if input matches Arrow Left key. - * Handles both legacy (\x1b[D) and Kitty protocol with modifiers. - */ -export function isArrowLeft(data: string): boolean { - return data === "\x1b[D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0); -} - -/** - * Check if input matches plain Tab key (no modifiers). - * Handles both legacy (\t) and Kitty protocol. - */ -export function isTab(data: string): boolean { - return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); -} - -/** - * Check if input matches plain Enter/Return key (no modifiers). - * Handles both legacy (\r) and Kitty protocol. - */ -export function isEnter(data: string): boolean { - return data === "\r" || matchesKittySequence(data, CODEPOINTS.enter, 0); -} - -/** - * Check if input matches plain Backspace key (no modifiers). - * Handles both legacy (\x7f, \x08) and Kitty protocol. - */ -export function isBackspace(data: string): boolean { - return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); -} - -/** - * Check if input matches Shift+Enter. - * Ignores lock key bits. - */ -export function isShiftEnter(data: string): boolean { - return data === Keys.SHIFT_ENTER || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift); -} - -/** - * Check if input matches Alt+Enter. - * Ignores lock key bits. - */ -export function isAltEnter(data: string): boolean { - return data === Keys.ALT_ENTER || data === "\x1b\r" || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt); -} - -/** - * Check if input matches Option/Alt+Left (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isAltLeft(data: string): boolean { - return data === "\x1b[1;3D" || data === "\x1bb" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt); -} - -/** - * Check if input matches Option/Alt+Right (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isAltRight(data: string): boolean { - return data === "\x1b[1;3C" || data === "\x1bf" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt); -} - -/** - * Check if input matches Ctrl+Left (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isCtrlLeft(data: string): boolean { - return data === "\x1b[1;5D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+Right (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isCtrlRight(data: string): boolean { - return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl); -} - -/** - * Check if input matches Home key. - * Handles legacy formats and Kitty protocol with lock key modifiers. - */ -export function isHome(data: string): boolean { - return ( - data === "\x1b[H" || - data === "\x1b[1~" || - data === "\x1b[7~" || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) - ); -} - -/** - * Check if input matches End key. - * Handles legacy formats and Kitty protocol with lock key modifiers. - */ -export function isEnd(data: string): boolean { - return ( - data === "\x1b[F" || - data === "\x1b[4~" || - data === "\x1b[8~" || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) - ); -} - -/** - * Check if input matches Delete key (forward delete). - * Handles legacy format and Kitty protocol with lock key modifiers. - */ -export function isDelete(data: string): boolean { - return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); + const parsed = parseKittySequence(data); + if (!parsed) return false; + // Caps Lock is modifier bit 64 + return (parsed.modifier & 64) !== 0; } diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index d733744b4..dbb248beb 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -51,6 +51,9 @@ export interface Terminal { clearLine(): void; // Clear current line clearFromCursor(): void; // Clear from cursor to end of screen clearScreen(): void; // Clear entire screen and move cursor to (0,0) + + // Title operations + setTitle(title: string): void; // Set terminal window title } /** @@ -160,4 +163,9 @@ export class ProcessTerminal implements Terminal { clearScreen(): void { process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } + + setTitle(title: string): void { + // OSC 0;title BEL - set terminal window title + process.stdout.write(`\x1b]0;${title}\x07`); + } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 8db0be89d..9efaddec5 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -582,4 +582,117 @@ describe("Editor component", () => { } }); }); + + describe("Word wrapping", () => { + it("wraps at word boundaries instead of mid-word", () => { + const editor = new Editor(defaultEditorTheme); + const width = 40; + + editor.setText("Hello world this is a test of word wrapping functionality"); + const lines = editor.render(width); + + // Check that all lines fit within width + for (const line of lines) { + const lineWidth = visibleWidth(line); + expect(lineWidth).toBe(width); + } + + // Extract text content (strip borders and control characters) + const allText = lines + .map((l) => stripVTControlCharacters(l)) + .join("") + .replace(/[+\-|]/g, "") + .replace(/\s+/g, " ") + .trim(); + + // Should contain the full text (with normalized whitespace) + expect(allText).toBe("Hello world this is a test of word wrapping functionality"); + }); + + it("does not start lines with leading whitespace after word wrap", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + editor.setText("Word1 Word2 Word3 Word4 Word5 Word6"); + const lines = editor.render(width); + + // Get content lines (between borders) + const contentLines = lines.slice(1, -1); + + // No line should start with whitespace (except for padding at the end) + for (let i = 0; i < contentLines.length; i++) { + const line = stripVTControlCharacters(contentLines[i]!); + const trimmedStart = line.trimStart(); + // The line should either be all padding or start with a word character + if (trimmedStart.length > 0) { + expect(/^\s+\S/.test(line.trimEnd())).toBe(false); + } + } + }); + + it("breaks long words (URLs) at character level", () => { + const editor = new Editor(defaultEditorTheme); + const width = 30; + + editor.setText("Check https://example.com/very/long/path/that/exceeds/width here"); + const lines = editor.render(width); + + // All lines should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + expect(lineWidth).toBe(width); + } + }); + + it("preserves multiple spaces within words on same line", () => { + const editor = new Editor(defaultEditorTheme); + const width = 50; + + editor.setText("Word1 Word2 Word3"); + const lines = editor.render(width); + + const contentLine = stripVTControlCharacters(lines[1]!).trim(); + // Multiple spaces should be preserved + expect(contentLine.includes("Word1 Word2")).toBeTruthy(); + }); + + it("handles empty string", () => { + const editor = new Editor(defaultEditorTheme); + const width = 40; + + editor.setText(""); + const lines = editor.render(width); + + // Should have at least 2 lines (borders) + expect(lines.length).toBeGreaterThanOrEqual(2); + + // All lines should fit within width + for (const line of lines) { + const lineWidth = visibleWidth(line); + expect(lineWidth).toBe(width); + } + }); + + it("handles single word that fits exactly", () => { + const editor = new Editor(defaultEditorTheme); + const width = 20; + + editor.setText("1234567890"); + const lines = editor.render(width); + + // Check all lines fit within width + for (const line of lines) { + const lineWidth = visibleWidth(line); + expect(lineWidth).toBe(width); + } + + // Extract and verify content + const allText = lines + .map((l) => stripVTControlCharacters(l)) + .join("") + .replace(/[+\-|]/g, "") + .trim(); + expect(allText).toBe("1234567890"); + }); + }); }); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index aa99fb877..e4291d5cf 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -86,6 +86,11 @@ export class VirtualTerminal implements Terminal { this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } + setTitle(title: string): void { + // OSC 0;title BEL - set terminal window title + this.xterm.write(`\x1b]0;${title}\x07`); + } + // Test-specific methods not in Terminal interface /** diff --git a/packages/web-ui/README.md b/packages/web-ui/README.md index 1601871de..d204d5894 100644 --- a/packages/web-ui/README.md +++ b/packages/web-ui/README.md @@ -209,7 +209,8 @@ agent.abort(); agent.setModel(newModel); agent.setThinkingLevel('medium'); agent.setTools([...]); -agent.queueMessage(customMessage); +agent.steer(customMessage); +agent.followUp(customMessage); ``` ## Message Types diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 4dea2f20e..f9f23ba96 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -343,11 +343,11 @@ const renderApp = () => { size: "sm", children: icon(Bell, "sm"), onClick: () => { - // Demo: Inject custom message (will appear on next agent run) + // Demo: Queue custom message for the next turn if (agent) { - agent.queueMessage( + agent.followUp( createSystemNotification( - "This is a custom message! It appears in the UI but is never sent to the LLM.", + "This is a custom message! It appears in the UI and is sent to the LLM.", ), ); } diff --git a/packages/web-ui/example/tsconfig.json b/packages/web-ui/example/tsconfig.json index bb751b5f3..4cb265327 100644 --- a/packages/web-ui/example/tsconfig.json +++ b/packages/web-ui/example/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "ES2022", + "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "moduleResolution": "bundler", "paths": { diff --git a/packages/web-ui/src/components/AgentInterface.ts b/packages/web-ui/src/components/AgentInterface.ts index a6be6a376..2d5f8fdcd 100644 --- a/packages/web-ui/src/components/AgentInterface.ts +++ b/packages/web-ui/src/components/AgentInterface.ts @@ -324,7 +324,7 @@ export class AgentInterface extends LitElement { if (this.onBeforeSend) { await this.onBeforeSend(); } - session.queueMessage({ role: "user", content: text, timestamp: Date.now() }); + session.steer({ role: "user", content: text, timestamp: Date.now() }); return; }