From eb59f1eb0077de6992226aa9ed3c131aa50ad829 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 7 May 2026 15:46:08 +0200 Subject: [PATCH] Add paged chat history loading --- eslint.config.js | 47 + package-lock.json | 989 ++++++++++++++++++ package.json | 6 + src/client/src/api.ts | 250 ++++- src/client/src/appState.ts | 24 +- src/client/src/chatGroups.ts | 11 +- src/client/src/chatMessages.ts | 99 +- src/client/src/components/AutocompleteMenu.ts | 6 +- src/client/src/components/ChatView.ts | 108 +- src/client/src/components/CommandPicker.ts | 14 +- src/client/src/components/Composer.ts | 12 +- src/client/src/components/FormattedText.ts | 4 +- src/client/src/components/PiWebApp.ts | 26 +- src/client/src/components/ProjectList.ts | 4 +- src/client/src/components/PromptEditor.ts | 64 +- src/client/src/components/SessionList.ts | 15 +- src/client/src/components/StatusBar.ts | 16 +- src/client/src/components/WorkspaceList.ts | 4 +- src/client/src/components/shared.ts | 4 + .../src/controllers/projectController.ts | 2 +- .../src/controllers/sessionController.ts | 34 +- src/client/src/controllers/types.ts | 6 +- .../src/controllers/workspaceController.ts | 6 +- src/client/src/formatting/markdown.ts | 4 +- src/client/src/route.ts | 12 +- src/client/src/sessionSocket.ts | 38 +- src/client/src/shellMessages.ts | 16 +- src/client/src/utils/format.ts | 4 +- src/server/index.ts | 8 +- src/server/projects/projectService.ts | 2 +- src/server/realtime/sessionEventHub.ts | 4 +- src/server/sessiond.ts | 9 +- src/server/sessiond/config.ts | 4 +- src/server/sessiond/sessionDaemonClient.ts | 25 +- src/server/sessiond/sessionProxyRoutes.ts | 34 +- src/server/sessions/piSessionService.ts | 196 ++-- src/server/sessions/sessionCommandService.ts | 29 +- src/server/sessions/sessionRoutes.ts | 21 +- src/server/storage/projectStore.ts | 34 +- src/server/types.ts | 6 + src/server/workspaces/workspaceService.ts | 21 +- tsconfig.json | 9 + 42 files changed, 1894 insertions(+), 333 deletions(-) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..32a8120 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,47 @@ +import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["src/**/*.ts", "vite.config.ts"], + extends: [ + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unsafe-assignment": "error", + "@typescript-eslint/no-unsafe-member-access": "error", + "@typescript-eslint/no-unsafe-call": "error", + "@typescript-eslint/no-unsafe-return": "error", + "@typescript-eslint/no-unsafe-argument": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-unnecessary-condition": "error", + "@typescript-eslint/strict-boolean-expressions": "error", + "@typescript-eslint/switch-exhaustiveness-check": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/no-unnecessary-type-assertion": "error", + "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }], + "@typescript-eslint/restrict-template-expressions": "error", + "@typescript-eslint/restrict-plus-operands": "error", + }, + }, +]); diff --git a/package-lock.json b/package-lock.json index a2e7024..8b7c9fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,14 @@ "ws": "^8.18.3" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^24.10.1", "@types/ws": "^8.18.1", + "eslint": "^10.3.0", + "globals": "^17.6.0", "tsx": "^4.20.6", "typescript": "^5.9.3", + "typescript-eslint": "^8.59.2", "vite": "^7.2.4" } }, @@ -1275,6 +1279,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -1494,6 +1626,72 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -3025,6 +3223,13 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3032,6 +3237,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime-types": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", @@ -3079,12 +3291,255 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3445,6 +3900,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -3579,6 +4041,19 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -3600,6 +4075,146 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -3613,6 +4228,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -3669,6 +4310,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stringify": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", @@ -3693,6 +4341,13 @@ "rfdc": "^1.2.0" } }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -3862,6 +4517,19 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -3894,6 +4562,44 @@ "node": ">=20" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4061,6 +4767,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -4198,6 +4930,16 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4222,6 +4964,16 @@ "node": ">= 10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4231,6 +4983,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4261,6 +5026,13 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -4299,6 +5071,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4320,6 +5099,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/koffi": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", @@ -4331,6 +5120,20 @@ "url": "https://liberapay.com/Koromix" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -4399,6 +5202,22 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -4523,6 +5342,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -4618,6 +5444,56 @@ } } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -4696,6 +5572,16 @@ "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -4828,6 +5714,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -4938,6 +5834,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -5486,6 +6392,19 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5512,6 +6431,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typebox": { "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", @@ -5532,6 +6464,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -5559,6 +6515,16 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5677,6 +6643,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5803,6 +6779,19 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 0aa2898..fb917f2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev:server": "npm run dev:web", "dev:client": "vite --host 0.0.0.0", "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.ts\" vite.config.ts", "start": "tsx src/server/index.ts", "start:sessiond": "tsx src/server/sessiond.ts" }, @@ -23,10 +25,14 @@ "ws": "^8.18.3" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^24.10.1", "@types/ws": "^8.18.1", + "eslint": "^10.3.0", + "globals": "^17.6.0", "tsx": "^4.20.6", "typescript": "^5.9.3", + "typescript-eslint": "^8.59.2", "vite": "^7.2.4" } } diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 2c76fe2..ddb5f32 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -64,38 +64,49 @@ export interface CommandOption { description?: string; } +export interface MessagePage { + messages: unknown[]; + start: number; + total: number; +} + export type CommandResult = | { type: "done"; message?: string; session?: SessionInfo } | { type: "select"; requestId: string; title: string; options: CommandOption[] } | { type: "unsupported"; message: string }; -async function request(url: string, init?: RequestInit): Promise { - const response = await fetch(url, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); +async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("content-type", "application/json"); + const response = await fetch(url, { ...init, headers }); if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.error ?? response.statusText); + const body: unknown = await response.json().catch((): unknown => ({})); + throw new Error(errorMessage(body) ?? response.statusText); } - return response.json() as Promise; + const body: unknown = await response.json(); + return parse(body); +} + +function errorMessage(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + return typeof value["error"] === "string" ? value["error"] : undefined; } export const api = { - projects: () => request("/api/projects"), - addProject: (path: string, name?: string) => request("/api/projects", { method: "POST", body: JSON.stringify({ path, name }) }), - workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`), - sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`), - startSession: (cwd: string) => request("/api/sessions", { method: "POST", body: JSON.stringify({ cwd }) }), - messages: (sessionId: string) => request(`/api/sessions/${sessionId}/messages`), - status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`), - commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`), - files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind ? `&kind=${encodeURIComponent(kind)}` : ""}`), - prompt: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/prompt`, { method: "POST", body: JSON.stringify({ text }) }), - shell: (sessionId: string, text: string) => request<{ accepted: true }>(`/api/sessions/${sessionId}/shell`, { method: "POST", body: JSON.stringify({ text }) }), - runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, { method: "POST", body: JSON.stringify({ text }) }), - respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, { method: "POST", body: JSON.stringify({ requestId, value }) }), - stop: (sessionId: string) => request<{ stopped: true }>(`/api/sessions/${sessionId}/stop`, { method: "POST" }), + projects: () => request("/api/projects", arrayOf(parseProject)), + addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }), + workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)), + sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)), + startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), + messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage), + status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus), + commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)), + files: (cwd: string, query: string, kind?: FileSuggestion["kind"]) => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}`, arrayOf(parseFileSuggestion)), + prompt: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }), + shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }), + runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }), + respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }), + stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }), }; export function sessionEvents(sessionId: string): WebSocket { @@ -106,7 +117,202 @@ export function globalSessionEvents(): WebSocket { return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`); } +function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string { + const params = new URLSearchParams(); + if (options?.limit !== undefined) params.set("limit", String(options.limit)); + if (options?.before !== undefined) params.set("before", String(options.before)); + const query = params.toString(); + return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`; +} + function webSocketBaseUrl(): string { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${location.host}`; } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function requireRecord(value: unknown): Record { + if (!isRecord(value)) throw new Error("Expected object response"); + return value; +} + +function requireString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string") throw new Error(`Expected string field: ${key}`); + return value; +} + +function optionalString(record: Record, key: string): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`); + return value; +} + +function requireNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== "number") throw new Error(`Expected number field: ${key}`); + return value; +} + +function requireBoolean(record: Record, key: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`); + return value; +} + +function arrayOf(parse: (value: unknown) => T): (value: unknown) => T[] { + return (value) => { + if (!Array.isArray(value)) throw new Error("Expected array response"); + return value.map(parse); + }; +} + +function parseUnknownArray(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error("Expected array response"); + return value; +} + +function parseMessagePage(value: unknown): MessagePage { + if (Array.isArray(value)) return { messages: value, start: 0, total: value.length }; + const record = requireRecord(value); + return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") }; +} + +function parseProject(value: unknown): Project { + const record = requireRecord(value); + return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") }; +} + +function parseWorkspace(value: unknown): Workspace { + const record = requireRecord(value); + const branch = optionalString(record, "branch"); + return { + id: requireString(record, "id"), + projectId: requireString(record, "projectId"), + path: requireString(record, "path"), + label: requireString(record, "label"), + ...(branch === undefined ? {} : { branch }), + isMain: requireBoolean(record, "isMain"), + isGitWorktree: requireBoolean(record, "isGitWorktree"), + }; +} + +function parseSessionInfo(value: unknown): SessionInfo { + const record = requireRecord(value); + const name = optionalString(record, "name"); + return { + id: requireString(record, "id"), + path: requireString(record, "path"), + cwd: requireString(record, "cwd"), + ...(name === undefined ? {} : { name }), + created: requireString(record, "created"), + modified: requireString(record, "modified"), + messageCount: requireNumber(record, "messageCount"), + firstMessage: requireString(record, "firstMessage"), + }; +} + +function parseSessionStatus(value: unknown): SessionStatus { + const record = requireRecord(value); + return { + sessionId: requireString(record, "sessionId"), + isStreaming: requireBoolean(record, "isStreaming"), + isCompacting: requireBoolean(record, "isCompacting"), + isBashRunning: requireBoolean(record, "isBashRunning"), + pendingMessageCount: requireNumber(record, "pendingMessageCount"), + tokens: parseTokens(record["tokens"]), + cost: requireNumber(record, "cost"), + ...optionalModel(record["model"]), + ...optionalContextUsage(record["contextUsage"]), + ...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")), + }; +} + +function parseTokens(value: unknown): SessionStatus["tokens"] { + const record = requireRecord(value); + return { + input: requireNumber(record, "input"), + output: requireNumber(record, "output"), + cacheRead: requireNumber(record, "cacheRead"), + cacheWrite: requireNumber(record, "cacheWrite"), + total: requireNumber(record, "total"), + }; +} + +function optionalModel(value: unknown): Pick | object { + if (value === undefined) return {}; + const record = requireRecord(value); + return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } }; +} + +function optionalContextUsage(value: unknown): Pick | object { + if (value === undefined) return {}; + const record = requireRecord(value); + return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } }; +} + +function parseSlashCommand(value: unknown): SlashCommand { + const record = requireRecord(value); + const source = requireString(record, "source"); + if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source"); + return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) }; +} + +function parseFileSuggestion(value: unknown): FileSuggestion { + const record = requireRecord(value); + const kind = requireString(record, "kind"); + if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind"); + return { path: requireString(record, "path"), kind }; +} + +function parseCommandResult(value: unknown): CommandResult { + const record = requireRecord(value); + const type = requireString(record, "type"); + if (type === "unsupported") return { type, message: requireString(record, "message") }; + if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) }; + if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) }; + throw new Error("Invalid command result type"); +} + +function parseCommandOption(value: unknown): CommandOption { + const record = requireRecord(value); + return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) }; +} + +function optionalSession(value: unknown): Pick, "session"> | object { + return value === undefined ? {} : { session: parseSessionInfo(value) }; +} + +function parseAccepted(value: unknown): { accepted: true } { + const record = requireRecord(value); + if (record["accepted"] !== true) throw new Error("Expected accepted response"); + return { accepted: true }; +} + +function parseStopped(value: unknown): { stopped: true } { + const record = requireRecord(value); + if (record["stopped"] !== true) throw new Error("Expected stopped response"); + return { stopped: true }; +} + +function optionalNumber(record: Record, key: string): number | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`); + return value; +} + +function numberOrNull(record: Record, key: string): number | null { + const value = record[key]; + if (value === null) return null; + if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`); + return value; +} + +function optionalField(key: string, value: unknown): object { + return value === undefined ? {} : { [key]: value }; +} diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index abfce25..219dcb9 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -6,14 +6,17 @@ export interface AppState { workspaces: Workspace[]; sessions: SessionInfo[]; messages: ChatLine[]; - selectedProject?: Project; - selectedWorkspace?: Workspace; - selectedSession?: SessionInfo; - status?: SessionStatus; - activity?: SessionActivity; + messagePageStart: number; + messagePageTotal: number; + isLoadingEarlierMessages: boolean; + selectedProject: Project | undefined; + selectedWorkspace: Workspace | undefined; + selectedSession: SessionInfo | undefined; + status: SessionStatus | undefined; + activity: SessionActivity | undefined; sessionStatuses: Record; sessionActivities: Record; - commandDialog?: Extract; + commandDialog: Extract | undefined; error: string; } @@ -23,8 +26,17 @@ export function initialAppState(): AppState { workspaces: [], sessions: [], messages: [], + messagePageStart: 0, + messagePageTotal: 0, + isLoadingEarlierMessages: false, + selectedProject: undefined, + selectedWorkspace: undefined, + selectedSession: undefined, + status: undefined, + activity: undefined, sessionStatuses: {}, sessionActivities: {}, + commandDialog: undefined, error: "", }; } diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts index 6d1abe8..f9dd4bf 100644 --- a/src/client/src/chatGroups.ts +++ b/src/client/src/chatGroups.ts @@ -4,7 +4,7 @@ export type ChatGroup = | { kind: "message"; message: ChatLine; index: number } | { kind: "group"; messages: ChatLine[]; startIndex: number }; -export function groupChatMessages(messages: ChatLine[]): ChatGroup[] { +export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGroup[] { const groups: ChatGroup[] = []; let eventMessages: ChatLine[] = []; let eventStartIndex = 0; @@ -23,10 +23,11 @@ export function groupChatMessages(messages: ChatLine[]): ChatGroup[] { const readableParts = message.parts.filter((part) => isReadablePart(message, part)); const technicalParts = message.parts.filter((part) => !isReadablePart(message, part)); - if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, index); + const absoluteIndex = indexOffset + index; + if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts }, absoluteIndex); if (readableParts.length) { flushEvents(); - groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index }); + groups.push({ kind: "message", message: { role: message.role, parts: readableParts }, index: absoluteIndex }); } }); flushEvents(); @@ -38,8 +39,8 @@ export function summarizeChatGroup(messages: ChatLine[]): string { acc[message.role] = (acc[message.role] ?? 0) + 1; return acc; }, {}); - const details = Object.entries(counts).map(([role, count]) => `${count} ${role}`).join(" · "); - return `${messages.length} ${messages.length === 1 ? "event" : "events"}${details ? ` · ${details}` : ""}`; + const details = Object.entries(counts).map(([role, count]) => `${String(count)} ${role}`).join(" · "); + return `${String(messages.length)} ${messages.length === 1 ? "event" : "events"}${details !== "" ? ` · ${details}` : ""}`; } function isReadablePart(message: ChatLine, part: ChatPart): boolean { diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index e46da10..06c0a0d 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -1,6 +1,6 @@ import type { ChatLine, ChatPart } from "./components/shared"; -export function normalizeMessages(messages: any[]): ChatLine[] { +export function normalizeMessages(messages: unknown[]): ChatLine[] { return messages.flatMap(normalizeMessage).filter((message) => message.parts.length > 0); } @@ -20,23 +20,27 @@ export function appendText(messages: ChatLine[], role: ChatLine["role"], text: s return [...messages, textMessage(role, text)]; } -function normalizeMessage(message: any): ChatLine[] { - if (message?.role === "bashExecution") return [normalizeBashExecution(message)]; - const role = normalizeRole(message?.role); - const parts = normalizeContent(message?.content, message); +function normalizeMessage(message: unknown): ChatLine[] { + if (getString(message, "role") === "bashExecution") return [normalizeBashExecution(message)]; + const role = normalizeRole(getString(message, "role")); + const parts = normalizeContent(getProperty(message, "content"), message); if (role === "tool") return [{ role, parts }]; const visible = parts.filter((part) => part.type !== "empty"); - return visible.length ? [{ role, parts: visible }] : []; + return visible.length > 0 ? [{ role, parts: visible }] : []; } -function normalizeBashExecution(message: any): ChatLine { - const lines = message.excludeFromContext ? ["excluded from context", "", `$ ${message.command ?? ""}`] : [`$ ${message.command ?? ""}`]; - if (message.output) lines.push("", String(message.output)); - if (message.exitCode != null) lines.push("", `exit ${message.exitCode}`); - if (message.cancelled) lines.push("", "cancelled"); - if (message.truncated) lines.push("", "output truncated"); - if (message.fullOutputPath) lines.push("", `full output: ${message.fullOutputPath}`); +function normalizeBashExecution(message: unknown): ChatLine { + const command = getString(message, "command") ?? ""; + const lines = getBoolean(message, "excludeFromContext") === true ? ["excluded from context", "", `$ ${command}`] : [`$ ${command}`]; + const output = getProperty(message, "output"); + if (output != null) lines.push("", stringifyPrimitive(output)); + const exitCode = getProperty(message, "exitCode"); + if (exitCode != null) lines.push("", `exit ${stringifyPrimitive(exitCode)}`); + if (getBoolean(message, "cancelled") === true) lines.push("", "cancelled"); + if (getBoolean(message, "truncated") === true) lines.push("", "output truncated"); + const fullOutputPath = getString(message, "fullOutputPath"); + if (fullOutputPath !== undefined && fullOutputPath !== "") lines.push("", `full output: ${fullOutputPath}`); return { role: "bash", parts: [{ type: "text", text: lines.join("\n") }] }; } @@ -47,33 +51,41 @@ function normalizeRole(role: unknown): ChatLine["role"] { return "system"; } -function normalizeContent(content: unknown, message: any): ChatPart[] { - if (typeof content === "string") return content ? [{ type: "text", text: content }] : []; +function normalizeContent(content: unknown, message: unknown): ChatPart[] { + if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : []; if (!Array.isArray(content)) return objectFallback(content); - return content.flatMap((part: any): ChatPart[] => { - if (part?.type === "text") return part.text ? [{ type: "text", text: part.text }] : []; - if (part?.type === "thinking") return part.thinking || part.text ? [{ type: "thinking", text: part.thinking ?? part.text }] : []; - if (part?.type === "toolCall") return [{ type: "toolCall", toolName: part.name ?? "tool", summary: summarizeArgs(part.arguments) }]; - if (part?.type === "image") return [{ type: "text", text: "[image]" }]; + return content.flatMap((part): ChatPart[] => { + const type = getString(part, "type"); + const text = getString(part, "text"); + if (type === "text") return text !== undefined && text !== "" ? [{ type: "text", text }] : []; + if (type === "thinking") { + const thinking = getString(part, "thinking") ?? text; + return thinking !== undefined && thinking !== "" ? [{ type: "thinking", text: thinking }] : []; + } + if (type === "toolCall") return [{ type: "toolCall", toolName: getString(part, "name") ?? "tool", summary: summarizeArgs(getProperty(part, "arguments")) }]; + if (type === "image") return [{ type: "text", text: "[image]" }]; return objectFallback(part); - }).map((part) => part.type === "text" && message?.role === "toolResult" - ? { type: "toolResult", toolName: message.toolName ?? "tool", text: part.text, isError: !!message.isError } + }).map((part) => part.type === "text" && getString(message, "role") === "toolResult" + ? { type: "toolResult", toolName: getString(message, "toolName") ?? "tool", text: part.text, isError: getBoolean(message, "isError") === true } : part); } function objectFallback(value: unknown): ChatPart[] { if (value == null) return []; if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }]; - return [{ type: "text", text: String(value) }]; + return [{ type: "text", text: stringifyPrimitive(value) }]; } -function summarizeArgs(args: any): string { - if (!args || typeof args !== "object") return args == null ? "" : String(args); - if (typeof args.command === "string") return args.command; - if (typeof args.path === "string") return args.path; - if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement"; - if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`; +function summarizeArgs(args: unknown): string { + if (!isRecord(args)) return stringifyPrimitive(args); + const command = getString(args, "command"); + if (command !== undefined) return command; + const path = getString(args, "path"); + if (path !== undefined) return path; + if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement"; + const edits = args["edits"]; + if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`; const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3); return entries.map(([key, value]) => `${key}: ${shortValue(value)}`).join(" · "); } @@ -81,7 +93,32 @@ function summarizeArgs(args: any): string { function shortValue(value: unknown): string { if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value; if (typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; - if (typeof value === "object" && value) return "object"; + if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`; + if (typeof value === "object" && value !== null) return "object"; + return ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getProperty(value: unknown, key: string): unknown { + return isRecord(value) ? value[key] : undefined; +} + +function getString(value: unknown, key: string): string | undefined { + const property = getProperty(value, key); + return typeof property === "string" ? property : undefined; +} + +function getBoolean(value: unknown, key: string): boolean | undefined { + const property = getProperty(value, key); + return typeof property === "boolean" ? property : undefined; +} + +function stringifyPrimitive(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); return ""; } diff --git a/src/client/src/components/AutocompleteMenu.ts b/src/client/src/components/AutocompleteMenu.ts index e8d075c..0fa52c2 100644 --- a/src/client/src/components/AutocompleteMenu.ts +++ b/src/client/src/components/AutocompleteMenu.ts @@ -8,7 +8,7 @@ export class AutocompleteMenu extends LitElement { @property({ type: Number }) selectedIndex = 0; @property({ attribute: false }) onPick?: (item: CompletionItem) => void; - render() { + override render() { if (!this.items.length) return null; return html` `; } - static styles = autocompleteStyles; + static override styles = autocompleteStyles; } diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 2e287ca..a0b92b6 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -5,41 +5,92 @@ import type { ChatLine, ChatPart } from "./shared"; import { chatStyles } from "./shared"; import "./FormattedText"; +function isScrollPosition(value: unknown): value is { index: number; offset: number } { + return typeof value === "object" + && value !== null + && "index" in value + && "offset" in value + && typeof value.index === "number" + && typeof value.offset === "number"; +} + @customElement("chat-view") export class ChatView extends LitElement { @property({ attribute: false }) messages: ChatLine[] = []; @property() sessionId = ""; + @property({ type: Number }) messageStart = 0; + @property({ type: Number }) messageTotal = 0; + @property({ type: Boolean }) hasMore = false; + @property({ type: Boolean }) loadingMore = false; + @property({ attribute: false }) onLoadMore?: () => void; @query(".chat") private chat?: HTMLDivElement; @state() private pinnedToBottom = true; @state() private openGroupKeys = new Set(); + @state() private loadedScrollPercent = 100; private suppressScrollSave = false; private saveScrollTimer?: number; - disconnectedCallback(): void { + override disconnectedCallback(): void { window.clearTimeout(this.saveScrollTimer); super.disconnectedCallback(); } - protected willUpdate(changed: Map): void { + protected override willUpdate(changed: Map): void { if (changed.has("sessionId")) this.openGroupKeys = this.readOpenGroupKeys(); this.pinnedToBottom = this.isNearBottom(); } - protected updated(changed: Map): void { + protected override updated(changed: Map): void { if (changed.has("sessionId")) return; if (changed.has("messages") && this.pinnedToBottom) this.scrollToBottom(); + this.updateLoadedScrollPercent(); } - render() { + override render() { return html` -
- ${groupChatMessages(this.messages).map((group) => group.kind === "message" - ? this.renderMessage(group.message, group.index) - : this.renderMessageGroup(group.messages, group.startIndex))} +
+ ${this.renderHistoryIndicator()} +
{ this.onScroll(); }}> + ${this.renderHistoryBoundary()} + ${groupChatMessages(this.messages, this.messageStart).map((group) => group.kind === "message" + ? this.renderMessage(group.message, group.index) + : this.renderMessageGroup(group.messages, group.startIndex))} +
`; } + private renderHistoryIndicator() { + if (!this.messages.length || this.messageTotal <= 0) return null; + const loadedCount = this.messages.length; + const loadedPercent = Math.min(100, Math.round((loadedCount / this.messageTotal) * 100)); + const olderCount = this.messageStart; + const fullHistory = olderCount <= 0 + ? "full history loaded" + : `${olderCount} older not loaded · ${loadedPercent}% loaded`; + return html` +
+
${fullHistory}
+
loaded scroll: ${this.loadedScrollPercent}% from top
+
+ `; + } + + private renderHistoryBoundary() { + const range = this.historyRangeLabel(); + if (this.loadingMore) return html`
Loading earlier messages…${range}
`; + if (this.hasMore) return html`
Scroll up to load earlier messages${range}
`; + if (this.messages.length) return html`
Beginning of session${range}
`; + return null; + } + + private historyRangeLabel() { + if (!this.messages.length || this.messageTotal <= 0) return null; + const from = this.messageStart + 1; + const to = this.messageStart + this.messages.length; + return html`Showing messages ${from}–${to} of ${this.messageTotal}`; + } + private renderMessage(message: ChatLine, index: number) { return html`
@@ -52,7 +103,7 @@ export class ChatView extends LitElement { private renderMessageGroup(messages: ChatLine[], startIndex: number) { const key = this.groupKey(startIndex); return html` -
this.onGroupToggle(key, event)}> +
{ this.onGroupToggle(key, event); }}> events ${summarizeChatGroup(messages)} @@ -84,7 +135,8 @@ export class ChatView extends LitElement { } private onGroupToggle(key: string, event: Event) { - const details = event.currentTarget as HTMLDetailsElement; + const details = event.currentTarget; + if (!(details instanceof HTMLDetailsElement)) return; const openGroupKeys = new Set(this.openGroupKeys); if (details.open) openGroupKeys.add(key); else openGroupKeys.delete(key); @@ -93,10 +145,20 @@ export class ChatView extends LitElement { } private onScroll() { + this.updateLoadedScrollPercent(); + if (this.chat && this.chat.scrollTop < 64 && this.hasMore && !this.loadingMore) this.onLoadMore?.(); this.pinnedToBottom = this.isNearBottom(); if (!this.suppressScrollSave) this.scheduleScrollPositionSave(); } + private updateLoadedScrollPercent(): void { + const chat = this.chat; + if (!chat) return; + const maxScroll = chat.scrollHeight - chat.clientHeight; + const percent = maxScroll <= 0 ? 100 : Math.round((chat.scrollTop / maxScroll) * 100); + this.loadedScrollPercent = Math.max(0, Math.min(100, percent)); + } + private isNearBottom(): boolean { const chat = this.chat; if (!chat) return true; @@ -154,7 +216,7 @@ export class ChatView extends LitElement { } const chatTop = chat.getBoundingClientRect().top; const position = { - index: Number(firstVisible.dataset.index ?? 0), + index: Number(firstVisible.dataset["index"] ?? 0), offset: firstVisible.getBoundingClientRect().top - chatTop, }; localStorage.setItem(this.storageKey(sessionId), JSON.stringify(position)); @@ -165,17 +227,17 @@ export class ChatView extends LitElement { private scheduleScrollPositionSave() { window.clearTimeout(this.saveScrollTimer); - this.saveScrollTimer = window.setTimeout(() => this.saveScrollPosition(), 180); + this.saveScrollTimer = window.setTimeout(() => { this.saveScrollPosition(); }, 180); } private readStoredScrollPosition(): { index: number; offset: number } | undefined { - if (!this.sessionId) return undefined; + if (this.sessionId === "") return undefined; try { const raw = localStorage.getItem(this.storageKey()); - if (!raw) return undefined; - const value = JSON.parse(raw); - if (typeof value?.index !== "number" || typeof value?.offset !== "number") return undefined; - return { index: value.index, offset: value.offset }; + if (raw === null || raw === "") return undefined; + const value: unknown = JSON.parse(raw); + if (!isScrollPosition(value)) return undefined; + return value; } catch { return undefined; } @@ -192,7 +254,7 @@ export class ChatView extends LitElement { } private articleAt(index: number): HTMLElement | undefined { - return this.articles().find((article) => Number(article.dataset.index) === index); + return this.articles().find((article) => Number(article.dataset["index"]) === index); } private articles(): HTMLElement[] { @@ -218,14 +280,14 @@ export class ChatView extends LitElement { } private groupKey(startIndex: number): string { - return `${this.sessionId}:${startIndex}`; + return `${this.sessionId}:${String(startIndex)}`; } private readOpenGroupKeys(): Set { - if (!this.sessionId) return new Set(); + if (this.sessionId === "") return new Set(); try { const raw = localStorage.getItem(this.groupStorageKey()); - const value = raw ? JSON.parse(raw) : []; + const value: unknown = raw !== null && raw !== "" ? JSON.parse(raw) : []; return new Set(Array.isArray(value) ? value.filter((item) => typeof item === "string") : []); } catch { return new Set(); @@ -233,7 +295,7 @@ export class ChatView extends LitElement { } private saveOpenGroupKeys(): void { - if (!this.sessionId) return; + if (this.sessionId === "") return; try { localStorage.setItem(this.groupStorageKey(), JSON.stringify([...this.openGroupKeys])); } catch { @@ -241,5 +303,5 @@ export class ChatView extends LitElement { } } - static styles = chatStyles; + static override styles = chatStyles; } diff --git a/src/client/src/components/CommandPicker.ts b/src/client/src/components/CommandPicker.ts index 679ecc9..ff72209 100644 --- a/src/client/src/components/CommandPicker.ts +++ b/src/client/src/components/CommandPicker.ts @@ -5,25 +5,25 @@ import { commandPickerStyles } from "./shared"; @customElement("command-picker") export class CommandPicker extends LitElement { - @property() title = "Select"; + @property() override title = "Select"; @property({ attribute: false }) options: CommandOption[] = []; @property({ attribute: false }) onPick?: (value: string) => void; @property({ attribute: false }) onCancel?: () => void; @state() private selectedIndex = 0; - render() { + override render() { return html`
this.onCancel?.()}> -
event.stopPropagation()}> +
{ event.stopPropagation(); }}>
${this.title}
-
this.handleKeyDown(event)} tabindex="0"> +
{ this.handleKeyDown(event); }} tabindex="0"> ${this.options.map((option, index) => html` `)}
@@ -32,7 +32,7 @@ export class CommandPicker extends LitElement { `; } - firstUpdated() { + override firstUpdated() { this.renderRoot.querySelector(".options")?.focus(); } @@ -53,5 +53,5 @@ export class CommandPicker extends LitElement { } } - static styles = commandPickerStyles; + static override styles = commandPickerStyles; } diff --git a/src/client/src/components/Composer.ts b/src/client/src/components/Composer.ts index b7251e1..2bcbb94 100644 --- a/src/client/src/components/Composer.ts +++ b/src/client/src/components/Composer.ts @@ -9,13 +9,15 @@ export class Composer extends LitElement { @property({ attribute: false }) onStopSession?: () => void; @state() private draft = ""; - render() { + override render() { return html`
- +
`; @@ -32,10 +34,10 @@ export class Composer extends LitElement { private send() { const text = this.draft.trim(); - if (!text || this.disabled) return; + if (text === "" || this.disabled) return; this.draft = ""; this.onSend?.(text); } - static styles = composerStyles; + static override styles = composerStyles; } diff --git a/src/client/src/components/FormattedText.ts b/src/client/src/components/FormattedText.ts index a6d8250..798f747 100644 --- a/src/client/src/components/FormattedText.ts +++ b/src/client/src/components/FormattedText.ts @@ -8,9 +8,9 @@ import { formattedTextStyles } from "./shared"; export class FormattedText extends LitElement { @property() text = ""; - render() { + override render() { return html`
${unsafeHTML(toSafeMarkdownHtml(this.text))}
`; } - static styles = formattedTextStyles; + static override styles = formattedTextStyles; } diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 24c3684..5baa462 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -25,30 +25,30 @@ export class PiWebApp extends LitElement { private readonly sessions = new SessionController( () => this.state, - (patch) => this.setState(patch), - () => this.updateUrl(), + (patch) => { this.setState(patch); }, + () => { this.updateUrl(); }, ); private readonly workspaces = new WorkspaceController( () => this.state, - (patch) => this.setState(patch), - () => this.updateUrl(), + (patch) => { this.setState(patch); }, + () => { this.updateUrl(); }, this.sessions, ); private readonly projects = new ProjectController( () => this.state, - (patch) => this.setState(patch), + (patch) => { this.setState(patch); }, this.workspaces, ); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); - connectedCallback(): void { + override connectedCallback(): void { super.connectedCallback(); window.addEventListener("popstate", this.onPopState); this.sessions.connectStatusUpdates(); void this.loadProjectsAndRestoreRoute(); } - disconnectedCallback(): void { + override disconnectedCallback(): void { window.removeEventListener("popstate", this.onPopState); this.sessions.dispose(); super.disconnectedCallback(); @@ -65,7 +65,7 @@ export class PiWebApp extends LitElement { private async restoreRoute(updateUrl: boolean) { const route = readRoute(); - if (!route.projectId) return; + if (route.projectId === undefined || route.projectId === "") return; const project = this.state.projects.find((p) => p.id === route.projectId); if (!project) return; await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); @@ -89,7 +89,7 @@ export class PiWebApp extends LitElement { }); } - render() { + override render() { const state = this.state; return html`
@@ -106,18 +106,18 @@ export class PiWebApp extends LitElement { ${state.error ? html`
${state.error}
` : null} ${state.selectedSession ? html` - + 0} .loadingMore=${state.isLoadingEarlierMessages} .onLoadMore=${() => this.withChatScrollTransition(() => this.sessions.loadEarlierMessages())}> this.sessions.send(text)} .onStopSession=${() => this.sessions.stopSession()}> - ${state.commandDialog ? html` this.sessions.respondToCommand(state.commandDialog!.requestId, value)} .onCancel=${() => this.sessions.cancelCommand()}>` : null} + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ` : html`
Select or start a session.
`}
`; } - static styles = appStyles; + static override styles = appStyles; } function nextFrame(): Promise { - return new Promise((resolve) => requestAnimationFrame(() => resolve())); + return new Promise((resolve) => requestAnimationFrame(() => { resolve(); })); } diff --git a/src/client/src/components/ProjectList.ts b/src/client/src/components/ProjectList.ts index 5baf830..fbd07c4 100644 --- a/src/client/src/components/ProjectList.ts +++ b/src/client/src/components/ProjectList.ts @@ -9,7 +9,7 @@ export class ProjectList extends LitElement { @property({ attribute: false }) selected?: Project; @property({ attribute: false }) onSelect?: (project: Project) => void; - render() { + override render() { return html`

Projects

@@ -22,5 +22,5 @@ export class ProjectList extends LitElement { `; } - static styles = listStyles; + static override styles = listStyles; } diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 56a01b1..5297f6e 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -18,20 +18,20 @@ export class PromptEditor extends LitElement { @state() private selectedIndex = 0; private requestVersion = 0; - protected willUpdate(changed: PropertyValues) { + protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId")) return; - const previousSessionId = changed.get("sessionId") as string | undefined; - if (previousSessionId) saveDraft(previousSessionId, this.draft); - this.draft = this.sessionId ? loadDraft(this.sessionId) : ""; + const previousSessionId = changed.get("sessionId"); + if (previousSessionId !== undefined && previousSessionId !== "") saveDraft(previousSessionId, this.draft); + this.draft = this.sessionId !== undefined && this.sessionId !== "" ? loadDraft(this.sessionId) : ""; this.completions = []; this.selectedIndex = 0; } - protected updated(changed: PropertyValues) { + protected override updated(changed: PropertyValues) { if (changed.has("draft") || changed.has("sessionId")) this.resizeTextarea(); } - render() { + override render() { const inputMode = inputModeForDraft(this.draft); const shellMode = inputMode.kind === "shell"; return html` @@ -40,14 +40,16 @@ export class PromptEditor extends LitElement { ${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null} - this.pick(item)}> + { this.pick(item); }}>
- + `; @@ -61,12 +63,12 @@ export class PromptEditor extends LitElement { const textarea = this.textarea; if (!textarea) return; textarea.style.height = "auto"; - textarea.style.height = `${textarea.scrollHeight}px`; + textarea.style.height = `${String(textarea.scrollHeight)}px`; } private updateDraft(value: string) { this.draft = value; - if (this.sessionId) saveDraft(this.sessionId, this.draft); + if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft); void this.refreshCompletions(); } @@ -74,19 +76,26 @@ export class PromptEditor extends LitElement { const trigger = this.currentTrigger(); const version = ++this.requestVersion; this.selectedIndex = 0; - if (!trigger) { + if (trigger === undefined) { this.completions = []; return; } - if (trigger.kind === "command" && this.sessionId) { - const commands = await api.commands(this.sessionId).catch(() => [] as SlashCommand[]); + if (trigger.kind === "command" && this.sessionId !== undefined && this.sessionId !== "") { + const commands = await api.commands(this.sessionId).catch(emptySlashCommands); if (version !== this.requestVersion) return; this.completions = commands .filter((command) => command.name.toLowerCase().includes(trigger.query.toLowerCase())) .slice(0, 12) - .map((command) => ({ kind: "command", replaceFrom: trigger.from, replaceTo: this.draft.length, insertText: `/${command.name}`, detail: command.source, description: command.description })); - } else if (trigger.kind === "file" && this.cwd) { - const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(() => [] as FileSuggestion[]); + .map((command) => ({ + kind: "command", + replaceFrom: trigger.from, + replaceTo: this.draft.length, + insertText: `/${command.name}`, + detail: command.source, + ...(command.description === undefined ? {} : { description: command.description }), + })); + } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { + const files = await api.files(this.cwd, trigger.query, trigger.fileKind).catch(emptyFileSuggestions); if (version !== this.requestVersion) return; this.completions = files .slice(0, 12) @@ -119,7 +128,8 @@ export class PromptEditor extends LitElement { } if (event.key === "Tab" || event.key === "Enter") { event.preventDefault(); - this.pick(this.completions[this.selectedIndex]); + const completion = this.completions[this.selectedIndex]; + if (completion !== undefined) this.pick(completion); return; } if (event.key === "Escape") { @@ -136,20 +146,28 @@ export class PromptEditor extends LitElement { private pick(item: CompletionItem) { this.draft = `${this.draft.slice(0, item.replaceFrom)}${item.insertText} ${this.draft.slice(item.replaceTo)}`; - if (this.sessionId) saveDraft(this.sessionId, this.draft); + if (this.sessionId !== undefined && this.sessionId !== "") saveDraft(this.sessionId, this.draft); this.completions = []; } private send() { const text = this.draft.trim(); - if (!text || this.disabled) return; + if (text === "" || this.disabled) return; this.draft = ""; - if (this.sessionId) clearDraft(this.sessionId); + if (this.sessionId !== undefined && this.sessionId !== "") clearDraft(this.sessionId); this.completions = []; this.onSend?.(text); } - static styles = promptEditorStyles; + static override styles = promptEditorStyles; +} + +function emptySlashCommands(): SlashCommand[] { + return []; +} + +function emptyFileSuggestions(): FileSuggestion[] { + return []; } const draftStoragePrefix = "pi-web:prompt-draft:"; diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts index 007f514..6888c2b 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -3,6 +3,11 @@ import { customElement, property } from "lit/decorators.js"; import type { SessionActivity, SessionInfo, SessionStatus } from "../api"; import { listStyles } from "./shared"; +function sessionLabel(session: SessionInfo): string { + if (session.name !== undefined && session.name !== "") return session.name; + return session.firstMessage !== "" ? session.firstMessage : session.id.slice(0, 8); +} + @customElement("session-list") export class SessionList extends LitElement { @property({ attribute: false }) sessions: SessionInfo[] = []; @@ -13,13 +18,13 @@ export class SessionList extends LitElement { @property({ attribute: false }) onSelect?: (session: SessionInfo) => void; @property({ attribute: false }) onStart?: () => void; - render() { + override render() { return html`

Sessions

${this.sessions.map((session) => html` `)}
@@ -30,13 +35,13 @@ export class SessionList extends LitElement { const status = this.statuses[session.id]; const activity = this.activities[session.id]; if (activity?.phase === "active") return `● ${activity.label} · `; - if (!status) return ""; + if (status === undefined) return ""; if (status.isStreaming) return "● streaming · "; if (status.isBashRunning) return "● bash · "; if (status.isCompacting) return "● compacting · "; - if (status.pendingMessageCount) return `● ${status.pendingMessageCount} pending · `; + if (status.pendingMessageCount > 0) return `● ${String(status.pendingMessageCount)} pending · `; return ""; } - static styles = listStyles; + static override styles = listStyles; } diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 00077ef..8b7f6b6 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -10,12 +10,12 @@ export class StatusBar extends LitElement { @property({ attribute: false }) activity?: SessionActivity; @property({ attribute: false }) workspace?: Workspace; - render() { + override render() { const status = this.status; - if (!status) return html`
No session status yet
`; + if (status === undefined) return html`
No session status yet
`; const model = status.model?.id ?? "no model"; - const provider = status.model?.provider ? `${status.model.provider}/` : ""; - const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount ? "queued" : "idle"; + const provider = status.model?.provider !== undefined && status.model.provider !== "" ? `${status.model.provider}/` : ""; + const state = status.isCompacting ? "compacting" : status.isBashRunning ? "bash" : status.isStreaming ? "running" : status.pendingMessageCount > 0 ? "queued" : "idle"; const active = state !== "idle" || this.activity?.phase === "active"; const context = status.contextUsage; const contextText = context @@ -34,17 +34,17 @@ export class StatusBar extends LitElement { ↓${formatTokenCount(tokens.output)} ${contextText} ${formatCost(status.cost)} - ${status.pendingMessageCount ? html`${status.pendingMessageCount} queued` : null} + ${status.pendingMessageCount > 0 ? html`${String(status.pendingMessageCount)} queued` : null}
`; } private activityText(state: string): string { const activity = this.activity; - if (!activity) return state; + if (activity === undefined) return state; if (state !== "idle" && activity.phase === "idle") return state; - return activity.detail ? `${activity.label}: ${activity.detail}` : activity.label; + return activity.detail !== undefined && activity.detail !== "" ? `${activity.label}: ${activity.detail}` : activity.label; } - static styles = statusBarStyles; + static override styles = statusBarStyles; } diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts index c0b36e5..8791f3a 100644 --- a/src/client/src/components/WorkspaceList.ts +++ b/src/client/src/components/WorkspaceList.ts @@ -9,7 +9,7 @@ export class WorkspaceList extends LitElement { @property({ attribute: false }) selected?: Workspace; @property({ attribute: false }) onSelect?: (workspace: Workspace) => void; - render() { + override render() { return html`

Workspaces

@@ -22,5 +22,5 @@ export class WorkspaceList extends LitElement { `; } - static styles = listStyles; + static override styles = listStyles; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index d44a6ff..762b566 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -50,7 +50,9 @@ export const listStyles = css` export const chatStyles = css` :host { display: block; min-height: 0; color: #e6edf3; font: 14px system-ui, sans-serif; } + .chat-wrap { position: relative; height: 100%; min-height: 0; } .chat { height: 100%; overflow: auto; padding: 16px; box-sizing: border-box; } + .history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; } .msg { margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; } .msg.user { border-color: #2f81f7; background: #0d2847; } .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; } @@ -64,6 +66,8 @@ export const chatStyles = css` .group-msg.tool { color: #d29922; } .group-msg.system { color: #ff7b72; } .group-msg.bash { color: #3fb950; } + .history-boundary { display: grid; gap: 3px; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; } + .history-boundary small { color: #6e7681; } .label { display: block; margin-bottom: 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } formatted-text.part { display: block; } .part + .part { margin-top: 10px; } diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 65eaf1b..09fc44f 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -16,7 +16,7 @@ export class ProjectController { async addProject() { const path = prompt("Project folder path"); - if (!path) return; + if (path === null || path === "") return; try { const project = await api.addProject(path); const projects = this.getState().projects; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 658f453..870d668 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -1,4 +1,6 @@ import { api, type CommandResult, type SessionActivity, type SessionInfo, type SessionStatus } from "../api"; + +const MESSAGE_PAGE_SIZE = 100; import { normalizeMessages, textMessage } from "../chatMessages"; import { applyTranscriptEvent } from "../chatTranscript"; import { isShellInput } from "../inputModes"; @@ -25,7 +27,7 @@ export class SessionController { clearActiveSession() { this.socket.close(); - this.setState({ selectedSession: undefined, messages: [], status: undefined, activity: undefined }); + this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined }); } async startSession() { @@ -40,22 +42,42 @@ export class SessionController { } } - async selectSession(session: SessionInfo, options?: { updateUrl?: boolean }) { + async selectSession(session: SessionInfo, options?: { updateUrl?: boolean | undefined }) { this.socket.close(); try { const buffered: SessionUiEvent[] = []; this.socket.connect(session.id, (event) => buffered.push(event)); - const [messages, status] = await Promise.all([api.messages(session.id), api.status(session.id)]); - this.setState({ selectedSession: session, messages: normalizeMessages(messages), status }); + const [page, status] = await Promise.all([api.messages(session.id, { limit: MESSAGE_PAGE_SIZE }), api.status(session.id)]); + this.setState({ selectedSession: session, messages: normalizeMessages(page.messages), messagePageStart: page.start, messagePageTotal: page.total, isLoadingEarlierMessages: false, status }); this.applyStatus(status); for (const event of buffered) this.applyEvent(event); - this.socket.setHandler((event) => this.applyEvent(event)); + this.socket.setHandler((event) => { this.applyEvent(event); }); if (options?.updateUrl !== false) this.updateUrl(); } catch (error) { this.setState({ error: String(error) }); } } + async loadEarlierMessages() { + const state = this.getState(); + const session = state.selectedSession; + if (!session || state.isLoadingEarlierMessages || state.messagePageStart <= 0) return; + this.setState({ isLoadingEarlierMessages: true }); + try { + const page = await api.messages(session.id, { before: state.messagePageStart, limit: MESSAGE_PAGE_SIZE }); + if (this.getState().selectedSession?.id !== session.id) return; + this.setState({ + messages: [...normalizeMessages(page.messages), ...this.getState().messages], + messagePageStart: page.start, + messagePageTotal: page.total, + }); + } catch (error) { + this.setState({ error: String(error) }); + } finally { + if (this.getState().selectedSession?.id === session.id) this.setState({ isLoadingEarlierMessages: false }); + } + } + async send(text: string) { const trimmed = text.trim(); if (trimmed.startsWith("/")) return this.runCommand(text); @@ -126,7 +148,7 @@ export class SessionController { return; } const message = result.type === "unsupported" ? result.message : result.message; - if (message) this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] }); + if (message !== undefined && message !== "") this.setState({ messages: [...this.getState().messages, textMessage(result.type === "unsupported" ? "system" : "tool", message)] }); if (result.type === "done" && result.session) { const current = this.getState().selectedSession; const sessions = [result.session, ...this.getState().sessions.filter((session) => session.id !== result.session?.id)]; diff --git a/src/client/src/controllers/types.ts b/src/client/src/controllers/types.ts index f830c88..2487377 100644 --- a/src/client/src/controllers/types.ts +++ b/src/client/src/controllers/types.ts @@ -5,7 +5,7 @@ export type SetState = (patch: Partial) => void; export type UpdateUrl = () => void; export interface RouteTarget { - workspaceId?: string; - sessionId?: string; - updateUrl?: boolean; + workspaceId?: string | undefined; + sessionId?: string | undefined; + updateUrl?: boolean | undefined; } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 81ac4fc..57c79fd 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -16,7 +16,7 @@ export class WorkspaceController { try { const workspaces = await api.workspaces(project.id); this.setState({ workspaces }); - const workspace = target?.workspaceId ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0]; + const workspace = target?.workspaceId !== undefined && target.workspaceId !== "" ? workspaces.find((w) => w.id === target.workspaceId) : workspaces[0]; if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) { @@ -24,14 +24,14 @@ export class WorkspaceController { } } - async selectWorkspace(workspace: Workspace, target?: { sessionId?: string; updateUrl?: boolean }) { + async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) { this.sessions.clearActiveSession(); this.setState({ selectedWorkspace: workspace, sessions: [], error: "" }); try { const sessions = await api.sessions(workspace.path); this.setState({ sessions }); const sessionId = target?.sessionId; - const session = sessionId ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0]; + const session = sessionId !== undefined && sessionId !== "" ? sessions.find((s) => s.id === sessionId || s.id.startsWith(sessionId)) : sessions[0]; if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) { diff --git a/src/client/src/formatting/markdown.ts b/src/client/src/formatting/markdown.ts index e3ea5d5..3ce1d47 100644 --- a/src/client/src/formatting/markdown.ts +++ b/src/client/src/formatting/markdown.ts @@ -1,7 +1,7 @@ import { marked } from "marked"; export function toSafeMarkdownHtml(text: string): string { - const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }) as string; + const html = marked.parse(escapeHtml(text), { async: false, breaks: true, gfm: true }); return sanitizeHtml(html); } @@ -15,7 +15,7 @@ function escapeHtml(text: string): string { function sanitizeHtml(html: string): string { const template = document.createElement("template"); template.innerHTML = html; - template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => node.remove()); + template.content.querySelectorAll("script, style, iframe, object, embed").forEach((node) => { node.remove(); }); template.content.querySelectorAll("*").forEach((element) => { for (const attribute of [...element.attributes]) { const name = attribute.name.toLowerCase(); diff --git a/src/client/src/route.ts b/src/client/src/route.ts index 12cdbb9..2e1a12a 100644 --- a/src/client/src/route.ts +++ b/src/client/src/route.ts @@ -1,7 +1,7 @@ export interface AppRoute { - projectId?: string; - workspaceId?: string; - sessionId?: string; + projectId: string | undefined; + workspaceId: string | undefined; + sessionId: string | undefined; } export function readRoute(): AppRoute { @@ -18,9 +18,9 @@ export function writeRoute(route: AppRoute): void { url.searchParams.delete("project"); url.searchParams.delete("workspace"); url.searchParams.delete("session"); - if (route.projectId) url.searchParams.set("project", route.projectId); - if (route.workspaceId) url.searchParams.set("workspace", route.workspaceId); - if (route.sessionId) url.searchParams.set("session", route.sessionId); + if (route.projectId !== undefined && route.projectId !== "") url.searchParams.set("project", route.projectId); + if (route.workspaceId !== undefined && route.workspaceId !== "") url.searchParams.set("workspace", route.workspaceId); + if (route.sessionId !== undefined && route.sessionId !== "") url.searchParams.set("session", route.sessionId); const next = `${url.pathname}${url.search}${url.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (next !== current) window.history.pushState({}, "", url); diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 2e7f6a1..8114b0a 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -13,9 +13,9 @@ export type SessionUiEvent = | { type: "session.error"; message: string }; export class SessionSocket { - private socket?: WebSocket; - private sessionId?: string; - private onEvent?: (event: SessionUiEvent) => void; + private socket: WebSocket | undefined; + private sessionId: string | undefined; + private onEvent: ((event: SessionUiEvent) => void) | undefined; private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; @@ -42,14 +42,14 @@ export class SessionSocket { } private open(): void { - if (!this.sessionId || !this.shouldReconnect) return; + if (this.sessionId === undefined || this.sessionId === "" || !this.shouldReconnect) return; const socket = sessionEvents(this.sessionId); this.socket = socket; socket.onopen = () => { this.reconnectDelay = 500; }; socket.onmessage = (message) => void this.handleMessage(message.data); - socket.onerror = () => socket.close(); + socket.onerror = () => { socket.close(); }; socket.onclose = () => { if (this.socket === socket) this.socket = undefined; this.scheduleReconnect(); @@ -61,7 +61,7 @@ export class SessionSocket { window.clearTimeout(this.reconnectTimer); const delay = this.reconnectDelay; this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000); - this.reconnectTimer = window.setTimeout(() => this.open(), delay); + this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay); } private async handleMessage(data: MessageEvent["data"]): Promise { @@ -71,8 +71,8 @@ export class SessionSocket { } export class GlobalSessionSocket { - private socket?: WebSocket; - private onEvent?: (event: Extract) => void; + private socket: WebSocket | undefined; + private onEvent: ((event: Extract) => void) | undefined; private reconnectTimer?: number; private reconnectDelay = 500; private shouldReconnect = false; @@ -100,7 +100,7 @@ export class GlobalSessionSocket { this.reconnectDelay = 500; }; socket.onmessage = (message) => void this.handleMessage(message.data); - socket.onerror = () => socket.close(); + socket.onerror = () => { socket.close(); }; socket.onclose = () => { if (this.socket === socket) this.socket = undefined; this.scheduleReconnect(); @@ -112,7 +112,7 @@ export class GlobalSessionSocket { window.clearTimeout(this.reconnectTimer); const delay = this.reconnectDelay; this.reconnectDelay = Math.min(this.reconnectDelay * 1.6, 5000); - this.reconnectTimer = window.setTimeout(() => this.open(), delay); + this.reconnectTimer = window.setTimeout(() => { this.open(); }, delay); } private async handleMessage(data: MessageEvent["data"]): Promise { @@ -121,12 +121,20 @@ export class GlobalSessionSocket { } } -function isSessionUiEvent(event: any): event is SessionUiEvent { - return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "status.update", "activity.update", "command.output", "session.error"].includes(event?.type); +function isSessionUiEvent(event: unknown): event is SessionUiEvent { + const type = eventType(event); + return ["assistant.delta", "tool.start", "tool.end", "shell.start", "shell.chunk", "shell.end", "status.update", "activity.update", "command.output", "session.error"].includes(type); } function isGlobalSessionEvent(event: unknown): event is Extract { - return typeof event === "object" && event !== null && ("type" in event) && ((event as any).type === "status.update" || (event as any).type === "activity.update"); + const type = eventType(event); + return type === "status.update" || type === "activity.update"; +} + +function eventType(event: unknown): string { + if (typeof event !== "object" || event === null || !("type" in event)) return ""; + const type = event.type; + return typeof type === "string" ? type : ""; } async function parseSocketEvent(data: MessageEvent["data"]): Promise { @@ -141,12 +149,12 @@ async function parseSocketEvent(data: MessageEvent["data"]): Promise { } function closeSocketQuietly(socket: WebSocket | undefined): void { - if (!socket) return; + if (socket === undefined) return; socket.onmessage = null; socket.onerror = null; socket.onclose = null; if (socket.readyState === WebSocket.CONNECTING) { - socket.onopen = () => socket.close(); + socket.onopen = () => { socket.close(); }; return; } socket.close(); diff --git a/src/client/src/shellMessages.ts b/src/client/src/shellMessages.ts index 88f0963..db1b735 100644 --- a/src/client/src/shellMessages.ts +++ b/src/client/src/shellMessages.ts @@ -3,7 +3,7 @@ import type { ChatLine } from "./components/shared"; import type { SessionUiEvent } from "./sessionSocket"; export function shellStartMessage(command: string, excludeFromContext?: boolean): ChatLine { - return textMessage("bash", `${excludeFromContext ? "excluded from context\n\n" : ""}$ ${command}`); + return textMessage("bash", `${excludeFromContext === true ? "excluded from context\n\n" : ""}$ ${command}`); } export function appendShellChunk(messages: ChatLine[], chunk: string): ChatLine[] { @@ -19,13 +19,13 @@ export function finalizeShellMessage(messages: ChatLine[], event: Extract("/api/projects/:projectId/workspaces" } }); -await registerSessionProxyRoutes(app); +registerSessionProxyRoutes(app); app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other" } }>("/api/files", async (request, reply) => { - if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); + if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); try { return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); } catch (error) { @@ -51,6 +51,6 @@ if (existsSync(clientDist)) { app.setNotFoundHandler((_request, reply) => reply.sendFile("index.html")); } -const port = Number(process.env.PI_WEB_PORT ?? process.env.PORT ?? 3000); -const host = process.env.PI_WEB_HOST ?? "127.0.0.1"; +const port = Number(process.env["PI_WEB_PORT"] ?? process.env["PORT"] ?? 3000); +const host = process.env["PI_WEB_HOST"] ?? "127.0.0.1"; await app.listen({ port, host }); diff --git a/src/server/projects/projectService.ts b/src/server/projects/projectService.ts index 0a414cb..1c572ab 100644 --- a/src/server/projects/projectService.ts +++ b/src/server/projects/projectService.ts @@ -13,7 +13,7 @@ export class ProjectService { const resolved = await realpath(input.path); const s = await stat(resolved); if (!s.isDirectory()) throw new Error("Project path must be a directory"); - return this.store.add({ name: input.name, path: resolved }); + return this.store.add(input.name === undefined ? { path: resolved } : { name: input.name, path: resolved }); } async requireProject(id: string): Promise { diff --git a/src/server/realtime/sessionEventHub.ts b/src/server/realtime/sessionEventHub.ts index 1c08db4..548e36e 100644 --- a/src/server/realtime/sessionEventHub.ts +++ b/src/server/realtime/sessionEventHub.ts @@ -11,7 +11,9 @@ export class SessionEventHub { this.socketsBySession.set(sessionId, sockets); } sockets.add(socket); - socket.on("close", () => sockets?.delete(socket)); + socket.on("close", () => { + sockets.delete(socket); + }); } addGlobal(socket: WebSocket): void { diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index dd8b7b0..d36b761 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -12,12 +12,13 @@ await app.register(fastifyWebsocket); const eventHub = new SessionEventHub(); const sessions = new PiSessionService(eventHub); -await registerSessionRoutes(app, sessions, eventHub); +registerSessionRoutes(app, sessions, eventHub); -const port = process.env.PI_WEB_SESSIOND_PORT ? Number(process.env.PI_WEB_SESSIOND_PORT) : undefined; -const host = process.env.PI_WEB_SESSIOND_HOST ?? "127.0.0.1"; +const portValue = process.env["PI_WEB_SESSIOND_PORT"]; +const port = portValue !== undefined && portValue !== "" ? Number(portValue) : undefined; +const host = process.env["PI_WEB_SESSIOND_HOST"] ?? "127.0.0.1"; -if (port) { +if (port !== undefined) { await app.listen({ port, host }); } else { const path = sessiondSocketPath(); diff --git a/src/server/sessiond/config.ts b/src/server/sessiond/config.ts index 10006b8..0a9d32b 100644 --- a/src/server/sessiond/config.ts +++ b/src/server/sessiond/config.ts @@ -2,9 +2,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; export function sessiondSocketPath(): string { - return process.env.PI_WEB_SESSIOND_SOCKET ?? join(homedir(), ".pi-web", "sessiond.sock"); + return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(homedir(), ".pi-web", "sessiond.sock"); } export function sessiondHttpUrl(): string | undefined { - return process.env.PI_WEB_SESSIOND_URL; + return process.env["PI_WEB_SESSIOND_URL"]; } diff --git a/src/server/sessiond/sessionDaemonClient.ts b/src/server/sessiond/sessionDaemonClient.ts index 8b0d5e2..88c27e3 100644 --- a/src/server/sessiond/sessionDaemonClient.ts +++ b/src/server/sessiond/sessionDaemonClient.ts @@ -8,12 +8,12 @@ export class SessionDaemonClient { async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }> { const payload = body === undefined ? undefined : JSON.stringify(body); - if (this.baseUrl) return this.requestUrl(method, path, payload); + if (this.baseUrl !== undefined && this.baseUrl !== "") return this.requestUrl(method, path, payload); return this.requestSocket(method, path, payload); } connectWebSocket(path: string): WebSocket { - if (this.baseUrl) { + if (this.baseUrl !== undefined && this.baseUrl !== "") { const url = new URL(path, this.baseUrl); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return new WebSocket(url); @@ -22,11 +22,12 @@ export class SessionDaemonClient { } private async requestUrl(method: string, path: string, payload?: string) { - const response = await fetch(new URL(path, this.baseUrl), { - method, - headers: payload ? { "content-type": "application/json" } : undefined, - body: payload, - }); + const init: RequestInit = { method }; + if (payload !== undefined && payload !== "") { + init.headers = { "content-type": "application/json" }; + init.body = payload; + } + const response = await fetch(new URL(path, this.baseUrl), init); return { statusCode: response.status, headers: Object.fromEntries(response.headers.entries()), @@ -41,13 +42,15 @@ export class SessionDaemonClient { socketPath: this.socketPath, path, method, - headers: payload + headers: payload !== undefined && payload !== "" ? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } : undefined, }, (response) => { - const chunks: Buffer[] = []; - response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + const chunks: Uint8Array[] = []; + response.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); response.on("end", () => { resolve({ statusCode: response.statusCode ?? 500, @@ -58,7 +61,7 @@ export class SessionDaemonClient { }, ); request.on("error", reject); - if (payload) request.write(payload); + if (payload !== undefined && payload !== "") request.write(payload); request.end(); }); } diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts index 056b90e..640de7b 100644 --- a/src/server/sessiond/sessionProxyRoutes.ts +++ b/src/server/sessiond/sessionProxyRoutes.ts @@ -2,15 +2,17 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import { WebSocket, type RawData } from "ws"; import { SessionDaemonClient } from "./sessionDaemonClient.js"; -export async function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): Promise { +export function registerSessionProxyRoutes(app: FastifyInstance, daemon = new SessionDaemonClient()): void { const proxy = async (request: { method: string; url: string; body?: unknown }, reply: FastifyReply) => { try { const upstream = await daemon.request(request.method, stripApiPrefix(request.url), request.body); reply.code(upstream.statusCode); - if (upstream.headers["content-type"]) reply.header("content-type", upstream.headers["content-type"]); - return upstream.body ? JSON.parse(upstream.body) : undefined; + const contentType = upstream.headers["content-type"]; + if (contentType !== undefined && contentType !== "") reply.header("content-type", contentType); + return upstream.body !== "" ? parseJson(upstream.body) : undefined; } catch (error) { requestFailed(reply, error); + return undefined; } }; @@ -36,22 +38,30 @@ export async function registerSessionProxyRoutes(app: FastifyInstance, daemon = } function stripApiPrefix(url: string): string { - return url.startsWith("/api") ? url.slice(4) || "/" : url; + const stripped = url.startsWith("/api") ? url.slice(4) : url; + return stripped === "" ? "/" : stripped; } -function requestFailed(reply: FastifyReply, error: unknown) { +function parseJson(text: string): unknown { + const value: unknown = JSON.parse(text); + return value; +} + +function requestFailed(reply: FastifyReply, error: unknown): void { reply.code(502).send({ error: `Session daemon unavailable: ${error instanceof Error ? error.message : String(error)}` }); } function bridgeSockets(client: WebSocket, upstream: WebSocket): void { - client.on("message", (data) => sendIfOpen(upstream, data)); - upstream.on("message", (data) => sendIfOpen(client, data)); - client.on("close", () => upstream.close()); - upstream.on("close", () => client.close()); - upstream.on("error", () => client.close()); - client.on("error", () => upstream.close()); + client.on("message", (data) => { sendIfOpen(upstream, data); }); + upstream.on("message", (data) => { sendIfOpen(client, data); }); + client.on("close", () => { upstream.close(); }); + upstream.on("close", () => { client.close(); }); + upstream.on("error", () => { client.close(); }); + client.on("error", () => { upstream.close(); }); } function sendIfOpen(socket: WebSocket, data: RawData): void { - if (socket.readyState === WebSocket.OPEN) socket.send(data); + if (socket.readyState === WebSocket.OPEN) { + socket.send(data); + } } diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 01820e8..d4e9774 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -7,15 +7,18 @@ import { ModelRegistry, SessionManager, type AgentSession, - type AgentSessionRuntime, type CreateAgentSessionRuntimeFactory, } from "@mariozechner/pi-coding-agent"; -import type { ClientCommand, ClientCommandResult, ClientSession, ClientSessionStatus } from "../types.js"; +import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionStatus } from "../types.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import { BUILTIN_COMMANDS } from "./builtinCommands.js"; import { SessionCommandService } from "./sessionCommandService.js"; import type { ActiveSession } from "./sessionRuntimeStore.js"; +function noop(): void { + // Intentionally empty default unsubscribe callback. +} + export class PiSessionService { private readonly active = new Map(); private readonly activities = new Map(); @@ -26,12 +29,15 @@ export class PiSessionService { private readonly modelRegistry = ModelRegistry.create(this.authStorage); private readonly createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => { const services = await createAgentSessionServices({ cwd, agentDir, authStorage: this.authStorage, modelRegistry: this.modelRegistry }); - const result = await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent }); + const options = sessionStartEvent === undefined + ? { services, sessionManager } + : { services, sessionManager, sessionStartEvent }; + const result = await createAgentSessionFromServices(options); return { ...result, services, diagnostics: services.diagnostics }; }; constructor(private readonly events: SessionEventHub) { - this.heartbeat = setInterval(() => this.publishHeartbeats(), 2000); + this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, 2000); this.commandService = new SessionCommandService( (sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text), @@ -45,7 +51,7 @@ export class PiSessionService { id: s.id, path: s.path, cwd: s.cwd, - name: s.name, + ...(s.name === undefined ? {} : { name: s.name }), created: s.created.toISOString(), modified: s.modified.toISOString(), messageCount: s.messageCount, @@ -67,9 +73,15 @@ export class PiSessionService { }; } - async messages(sessionId: string): Promise { + async messages(sessionId: string, page?: { before?: number; limit?: number }): Promise { const session = await this.getOrOpen(sessionId); - return session.messages; + const messages = historyMessages(session); + if (page?.before === undefined && page?.limit === undefined) return messages; + const total = messages.length; + const before = clampInteger(page.before ?? total, 0, total); + const limit = clampInteger(page.limit ?? 100, 1, 500); + const start = Math.max(0, before - limit); + return { messages: messages.slice(start, before), start, total }; } async status(sessionId: string): Promise { @@ -80,7 +92,7 @@ export class PiSessionService { const session = await this.getOrOpen(sessionId); const commands: ClientCommand[] = [...BUILTIN_COMMANDS]; for (const command of session.extensionRunner.getRegisteredCommands()) { - commands.push({ name: command.invocationName, description: command.description, source: "extension" }); + commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" }); } for (const template of session.promptTemplates) { commands.push({ name: template.name, description: template.description, source: "prompt" }); @@ -94,7 +106,7 @@ export class PiSessionService { async prompt(sessionId: string, text: string): Promise { const session = await this.getOrOpen(sessionId); this.publishActivity(session, "prompt accepted", "active"); - void session.prompt(text).catch((error) => { + void session.prompt(text).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); this.publishActivity(session, "error", "error", message); this.events.publish(sessionId, { type: "session.error", message }); @@ -126,7 +138,7 @@ export class PiSessionService { }); this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command); this.publishStatus(session); - }).catch((error) => { + }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true }); this.events.publish(session.sessionId, { type: "session.error", message }); @@ -172,9 +184,12 @@ export class PiSessionService { private async create(sessionManager: SessionManager, cwd: string): Promise { const runtime = await createAgentSessionRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager }); - const active: ActiveSession = { runtime, unsubscribe: () => {} }; + const active: ActiveSession = { runtime, unsubscribe: noop }; this.bindRuntime(active); - runtime.setRebindSession(async () => this.bindRuntime(active)); + runtime.setRebindSession(() => { + this.bindRuntime(active); + return Promise.resolve(); + }); this.active.set(runtime.session.sessionId, active); this.publishStatus(runtime.session); return active; @@ -214,9 +229,11 @@ export class PiSessionService { return "active"; } - private publishActivityForEvent(session: AgentSession, event: any): void { - if (event.type === "agent_start") return this.publishActivity(session, "agent running", "active"); - if (event.type === "agent_end") { + private publishActivityForEvent(session: AgentSession, event: unknown): void { + const eventType = getString(event, "type"); + if (eventType === undefined) return; + if (eventType === "agent_start") { this.publishActivity(session, "agent running", "active"); return; } + if (eventType === "agent_end") { this.publishActivity(session, "idle", "idle"); setTimeout(() => { this.publishActivity(session, "idle", "idle"); @@ -224,21 +241,26 @@ export class PiSessionService { }, 250); return; } - if (event.type === "turn_end") return this.publishActivity(session, "turn complete", "active"); - if (event.type === "message_start") return this.publishActivity(session, "message started", "active"); - if (event.type === "message_end") return this.publishActivity(session, "message complete", "idle"); - if (event.type === "message_update") return this.publishActivity(session, "receiving response", "active"); - if (event.type === "tool_execution_start") return this.publishActivity(session, "running tool", "active", event.toolName); - if (event.type === "tool_execution_end") return this.publishActivity(session, event.isError ? "tool failed" : "tool complete", event.isError ? "error" : "active", event.toolName); - if (event.type === "bash_execution_start") return this.publishActivity(session, "running bash", "active"); - if (event.type === "bash_execution_end") return this.publishActivity(session, "bash complete", "active"); - this.publishActivity(session, event.type.replaceAll("_", " "), "active"); + if (eventType === "turn_end") { this.publishActivity(session, "turn complete", "active"); return; } + if (eventType === "message_start") { this.publishActivity(session, "message started", "active"); return; } + if (eventType === "message_end") { this.publishActivity(session, "message complete", "idle"); return; } + if (eventType === "message_update") { this.publishActivity(session, "receiving response", "active"); return; } + if (eventType === "tool_execution_start") { this.publishActivity(session, "running tool", "active", getString(event, "toolName")); return; } + if (eventType === "tool_execution_end") { + const isError = getBoolean(event, "isError") === true; + this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "active", getString(event, "toolName")); + return; + } + if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; } + if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "active"); return; } + this.publishActivity(session, eventType.replaceAll("_", " "), "active"); } private publishActivity(session: AgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { const at = new Date().toISOString(); - this.activities.set(session.sessionId, { phase, label, detail, at }); - const activity = { sessionId: session.sessionId, phase, label, detail, at }; + const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at }; + this.activities.set(session.sessionId, stored); + const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at }; this.events.publish(session.sessionId, { type: "activity.update", activity }); this.events.publishGlobal({ type: "activity.update", activity }); } @@ -251,17 +273,23 @@ export class PiSessionService { private statusFromSession(session: AgentSession): ClientSessionStatus { const stats = session.getSessionStats(); - return { - sessionId: session.sessionId, - model: session.model - ? { + const model = session.model === undefined + ? undefined + : (() => { + const name = getString(session.model, "name"); + const reasoning = getProperty(session.model, "reasoning"); + return { provider: session.model.provider, id: session.model.id, - name: (session.model as any).name, + ...(name === undefined ? {} : { name }), contextWindow: session.model.contextWindow, - reasoning: (session.model as any).reasoning, - } - : undefined, + ...(reasoning === undefined ? {} : { reasoning }), + }; + })(); + const contextUsage = session.getContextUsage(); + return { + sessionId: session.sessionId, + ...(model === undefined ? {} : { model }), thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, @@ -269,33 +297,54 @@ export class PiSessionService { pendingMessageCount: session.pendingMessageCount, tokens: stats.tokens, cost: stats.cost, - contextUsage: session.getContextUsage(), + ...(contextUsage === undefined ? {} : { contextUsage }), }; } } -function toClientEvent(event: any): unknown { - if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") { - return { type: "assistant.delta", text: event.assistantMessageEvent.delta }; +function historyMessages(session: AgentSession): unknown[] { + const messages: unknown[] = []; + for (const entry of session.sessionManager.getBranch()) { + if (entry.type === "message") messages.push(entry.message); + else if (entry.type === "custom_message" && entry.display) messages.push({ role: "custom", content: entry.content, customType: entry.customType, details: entry.details }); + else if (entry.type === "compaction") messages.push({ role: "system", content: `Compacted history:\n\n${entry.summary}` }); + else if (entry.type === "branch_summary") messages.push({ role: "system", content: `Branch summary:\n\n${entry.summary}` }); } - if (event.type === "tool_execution_start") { - return { type: "tool.start", toolName: event.toolName, toolCallId: event.toolCallId, summary: summarizeToolArgs(event.args) }; - } - if (event.type === "tool_execution_end") { - return { type: "tool.end", toolName: event.toolName, toolCallId: event.toolCallId, text: stringifyToolResult(event.result), isError: event.isError }; - } - if (event.type === "agent_start") return { type: "agent.start" }; - if (event.type === "agent_end") return { type: "agent.end" }; - if (event.type === "message_end") return { type: "message.end" }; - return { type: "pi.event", eventType: event.type }; + return messages; } -function summarizeToolArgs(args: any): string { - if (!args || typeof args !== "object") return args == null ? "" : String(args); - if (typeof args.command === "string") return args.command; - if (typeof args.path === "string") return args.path; - if (typeof args.oldText === "string" && typeof args.newText === "string") return "edit text replacement"; - if (Array.isArray(args.edits)) return `${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}`; +function clampInteger(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return max; + return Math.max(min, Math.min(max, Math.floor(value))); +} + +function toClientEvent(event: unknown): unknown { + const eventType = getString(event, "type"); + const assistantMessageEvent = getProperty(event, "assistantMessageEvent"); + if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") { + return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" }; + } + if (eventType === "tool_execution_start") { + return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(getProperty(event, "args")) }; + } + if (eventType === "tool_execution_end") { + return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(getProperty(event, "result")), isError: getBoolean(event, "isError") === true }; + } + if (eventType === "agent_start") return { type: "agent.start" }; + if (eventType === "agent_end") return { type: "agent.end" }; + if (eventType === "message_end") return { type: "message.end" }; + return { type: "pi.event", eventType: eventType ?? "unknown" }; +} + +function summarizeToolArgs(args: unknown): string { + if (!isRecord(args)) return stringifyPrimitive(args); + const command = getString(args, "command"); + if (command !== undefined) return command; + const path = getString(args, "path"); + if (path !== undefined) return path; + if (typeof args["oldText"] === "string" && typeof args["newText"] === "string") return "edit text replacement"; + const edits = args["edits"]; + if (Array.isArray(edits)) return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`; const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3); return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · "); } @@ -303,18 +352,43 @@ function summarizeToolArgs(args: any): string { function shortToolValue(value: unknown): string { if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value; if (typeof value === "number" || typeof value === "boolean") return String(value); - if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`; - if (typeof value === "object" && value) return "object"; + if (Array.isArray(value)) return `${String(value.length)} item${value.length === 1 ? "" : "s"}`; + if (typeof value === "object" && value !== null) return "object"; return ""; } function stringifyToolResult(result: unknown): string { if (typeof result === "string") return result; - if (Array.isArray(result)) return result.map(stringifyToolResult).filter(Boolean).join("\n"); - if (result && typeof result === "object") { - const text = (result as any).text ?? (result as any).content ?? (result as any).output; - if (typeof text === "string") return text; + if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n"); + if (isRecord(result)) { + const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output"); + if (text !== undefined) return text; return JSON.stringify(result, null, 2); } - return result == null ? "" : String(result); + return stringifyPrimitive(result); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getProperty(value: unknown, key: string): unknown { + return isRecord(value) ? value[key] : undefined; +} + +function getString(value: unknown, key: string): string | undefined { + const property = getProperty(value, key); + return typeof property === "string" ? property : undefined; +} + +function getBoolean(value: unknown, key: string): boolean | undefined { + const property = getProperty(value, key); + return typeof property === "boolean" ? property : undefined; +} + +function stringifyPrimitive(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); + return ""; } diff --git a/src/server/sessions/sessionCommandService.ts b/src/server/sessions/sessionCommandService.ts index dde69f1..2aa93aa 100644 --- a/src/server/sessions/sessionCommandService.ts +++ b/src/server/sessions/sessionCommandService.ts @@ -44,26 +44,23 @@ export class SessionCommandService { async respond(sessionId: string, requestId: string, value: string): Promise { const pending = this.pendingSelects.get(requestId); - if (!pending || pending.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" }; + if (pending?.sessionId !== sessionId) return { type: "unsupported", message: "Command request expired" }; this.pendingSelects.delete(requestId); const active = await this.getActive(sessionId); - if (pending.command === "fork") { - const result = await active.runtime.fork(value); - if (result.cancelled) return { type: "done", message: "Fork cancelled" }; - return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) }; - } - return { type: "unsupported", message: "Unsupported command response" }; + const result = await active.runtime.fork(value); + if (result.cancelled) return { type: "done", message: "Fork cancelled" }; + return { type: "done", message: "Session forked", session: clientSessionFromRuntime(active.runtime) }; } private nameSession(active: ActiveSession, name: string): ClientCommandResult { - if (!name) return { type: "unsupported", message: "Usage: /name " }; + if (name === "") return { type: "unsupported", message: "Usage: /name " }; active.runtime.session.setSessionName(name); return { type: "done", message: `Session named: ${name}`, session: clientSessionFromRuntime(active.runtime) }; } private compact(session: AgentSession, instructions: string): ClientCommandResult { - void session.compact(instructions || undefined) + void session.compact(instructions === "" ? undefined : instructions) .then((result) => { this.events.publish(session.sessionId, { type: "command.output", @@ -71,7 +68,7 @@ export class SessionCommandService { message: formatCompactionResult(result), }); }) - .catch((error) => { + .catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); this.events.publish(session.sessionId, { type: "command.output", level: "error", message: `Compaction failed: ${message}` }); this.events.publish(session.sessionId, { type: "session.error", message }); @@ -81,7 +78,7 @@ export class SessionCommandService { private async clone(active: ActiveSession): Promise { const leafId = active.runtime.session.sessionManager.getLeafId(); - if (!leafId) return { type: "unsupported", message: "Cannot clone: no current session entry" }; + if (leafId === null || leafId === "") return { type: "unsupported", message: "Cannot clone: no current session entry" }; const result = await active.runtime.fork(leafId, { position: "at" }); if (result.cancelled) return { type: "done", message: "Clone cancelled" }; return { type: "done", message: "Session cloned", session: clientSessionFromRuntime(active.runtime) }; @@ -113,7 +110,7 @@ function clientSessionFromRuntime(runtime: AgentSessionRuntime): ClientSession { id: session.sessionId, path: session.sessionFile ?? "", cwd: runtime.cwd, - name: session.sessionName, + ...(session.sessionName === undefined ? {} : { name: session.sessionName }), created: new Date().toISOString(), modified: new Date().toISOString(), messageCount: session.messages.length, @@ -125,9 +122,9 @@ function formatSessionStats(session: AgentSession): string { const stats = session.getSessionStats(); return [ `Session: ${stats.sessionId}`, - `Messages: ${stats.totalMessages} (${stats.userMessages} user, ${stats.assistantMessages} assistant)`, - `Tool calls: ${stats.toolCalls}`, - `Tokens: ↑${stats.tokens.input} ↓${stats.tokens.output} total ${stats.tokens.total}`, + `Messages: ${String(stats.totalMessages)} (${String(stats.userMessages)} user, ${String(stats.assistantMessages)} assistant)`, + `Tool calls: ${String(stats.toolCalls)}`, + `Tokens: ↑${String(stats.tokens.input)} ↓${String(stats.tokens.output)} total ${String(stats.tokens.total)}`, `Cost: $${stats.cost.toFixed(4)}`, ].join("\n"); } @@ -135,7 +132,7 @@ function formatSessionStats(session: AgentSession): string { function formatCompactionResult(result: { summary: string; tokensBefore: number }): string { return [ "Compaction complete.", - `Tokens before: ${result.tokensBefore}`, + `Tokens before: ${String(result.tokensBefore)}`, "", result.summary, ].join("\n"); diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 527dfa4..849658e 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -2,9 +2,9 @@ import type { FastifyInstance } from "fastify"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiSessionService } from "./piSessionService.js"; -export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): Promise { +export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void { app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => { - if (!request.query.cwd) return reply.code(400).send({ error: "cwd query parameter is required" }); + if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); return sessions.list(request.query.cwd); }); @@ -16,9 +16,10 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe } }); - app.get<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { + app.get<{ Params: { sessionId: string }; Querystring: { before?: string; limit?: string } }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => { try { - return await sessions.messages(request.params.sessionId); + const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) }; + return await sessions.messages(request.params.sessionId, page); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } @@ -79,7 +80,7 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe return { aborted: true }; }); - app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, async (request) => { + app.post<{ Params: { sessionId: string } }>(`${prefix}/sessions/:sessionId/stop`, (request) => { sessions.stop(request.params.sessionId); return { stopped: true }; }); @@ -92,3 +93,13 @@ export async function registerSessionRoutes(app: FastifyInstance, sessions: PiSe eventHub.addGlobal(socket); }); } + +function optionalField(key: string, value: T | undefined): Record | object { + return value === undefined ? {} : { [key]: value }; +} + +function optionalNumber(value: string | undefined): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/src/server/storage/projectStore.ts b/src/server/storage/projectStore.ts index 489bf3f..b1b8d0f 100644 --- a/src/server/storage/projectStore.ts +++ b/src/server/storage/projectStore.ts @@ -8,6 +8,29 @@ interface ProjectFile { projects: Project[]; } +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} + +function parseProjectFile(value: unknown): ProjectFile { + if (!isRecord(value) || !Array.isArray(value["projects"])) throw new Error("Invalid project file"); + return { projects: value["projects"].map(parseProject) }; +} + +function parseProject(value: unknown): Project { + if (!isRecord(value)) throw new Error("Invalid project"); + const id = value["id"]; + const name = value["name"]; + const path = value["path"]; + const createdAt = value["createdAt"]; + if (typeof id !== "string" || typeof name !== "string" || typeof path !== "string" || typeof createdAt !== "string") throw new Error("Invalid project"); + return { id, name, path, createdAt }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + export class ProjectStore { constructor(private readonly filePath = join(homedir(), ".pi-web", "projects.json")) {} @@ -21,9 +44,11 @@ export class ProjectStore { const existing = data.projects.find((p) => p.path === path); if (existing) return existing; + const trimmedName = input.name?.trim(); + const leafName = path.split("/").filter((part) => part !== "").at(-1); const project: Project = { id: randomUUID(), - name: input.name?.trim() || path.split("/").filter(Boolean).at(-1) || path, + name: trimmedName !== undefined && trimmedName !== "" ? trimmedName : leafName ?? path, path, createdAt: new Date().toISOString(), }; @@ -38,9 +63,10 @@ export class ProjectStore { private async read(): Promise { try { - return JSON.parse(await readFile(this.filePath, "utf8")) as ProjectFile; - } catch (error: any) { - if (error?.code === "ENOENT") return { projects: [] }; + const value: unknown = JSON.parse(await readFile(this.filePath, "utf8")); + return parseProjectFile(value); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return { projects: [] }; throw error; } } diff --git a/src/server/types.ts b/src/server/types.ts index be0d201..22f0614 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -26,6 +26,12 @@ export interface ClientSession { firstMessage: string; } +export interface ClientMessagePage { + messages: unknown[]; + start: number; + total: number; +} + export interface ClientSessionStatus { sessionId: string; model?: { provider?: string; id?: string; name?: string; contextWindow?: number; reasoning?: unknown }; diff --git a/src/server/workspaces/workspaceService.ts b/src/server/workspaces/workspaceService.ts index 123da5b..bb50aec 100644 --- a/src/server/workspaces/workspaceService.ts +++ b/src/server/workspaces/workspaceService.ts @@ -14,15 +14,18 @@ export class WorkspaceService { const worktrees = await discoverGitWorktrees(project.path); if (worktrees.length === 0) return [this.single(project)]; - return worktrees.map((worktree) => ({ - id: idFor(`${project.id}:${worktree.path}`), - projectId: project.id, - path: worktree.path, - label: worktree.branch || (worktree.detached ? "detached" : worktree.path.split("/").filter(Boolean).at(-1) || worktree.path), - branch: worktree.branch, - isMain: worktree.path === project.path, - isGitWorktree: true, - })); + return worktrees.map((worktree) => { + const leafName = worktree.path.split("/").filter((part) => part !== "").at(-1); + return { + id: idFor(`${project.id}:${worktree.path}`), + projectId: project.id, + path: worktree.path, + label: worktree.branch ?? (worktree.detached === true ? "detached" : leafName ?? worktree.path), + ...(worktree.branch === undefined ? {} : { branch: worktree.branch }), + isMain: worktree.path === project.path, + isGitWorktree: true, + }; + }); } private single(project: Project): Workspace { diff --git a/tsconfig.json b/tsconfig.json index 552358f..a187b00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,15 @@ "module": "ESNext", "moduleResolution": "Bundler", "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, "noEmit": true, "skipLibCheck": true, "experimentalDecorators": true,