From 99a8bff8d22eb92d633b136c45878ef45af342ce Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Feb 2026 20:17:32 +0200 Subject: [PATCH] feat: integrate terminal service and enhance CLI authentication handling - Added PtyTerminalService to manage PTY terminal processes, enabling terminal functionalities within the application. - Updated CLI installer to include authentication status checks, providing feedback on user login state. - Enhanced the dashboard with a warning banner for CLI installation status, prompting users to log in if not authenticated. - Introduced IPC channels for terminal operations, allowing communication between main and renderer processes for terminal management. - Improved TaskCommentsSection and other components to support new features and enhance user experience during task management. --- electron.vite.config.ts | 6 +- package.json | 17 +- pnpm-lock.yaml | 306 +++++++++++++++++- src/main/index.ts | 17 +- src/main/ipc/handlers.ts | 16 +- src/main/ipc/terminal.ts | 84 +++++ .../infrastructure/CliInstallerService.ts | 18 ++ .../infrastructure/PtyTerminalService.ts | 112 +++++++ src/main/services/infrastructure/index.ts | 1 + .../services/team/ChangeExtractorService.ts | 105 +++--- src/main/services/team/HunkSnippetMatcher.ts | 155 +++++++++ src/main/services/team/MemberStatsComputer.ts | 80 +++-- .../services/team/ReviewApplierService.ts | 40 ++- src/main/services/team/TeamTaskReader.ts | 11 +- src/main/services/team/UnifiedLineCounter.ts | 25 ++ src/main/services/team/index.ts | 2 + src/preload/constants/ipcChannels.ts | 22 ++ src/preload/index.ts | 40 +++ src/renderer/api/httpClient.ts | 18 ++ .../components/dashboard/CliStatusBanner.tsx | 71 +++- .../team/dialogs/TaskCommentsSection.tsx | 236 +++++++------- .../components/terminal/EmbeddedTerminal.tsx | 126 ++++++++ .../components/terminal/TerminalModal.tsx | 89 +++++ src/shared/types/api.ts | 4 + src/shared/types/cliInstaller.ts | 4 + src/shared/types/index.ts | 3 + src/shared/types/review.ts | 2 + src/shared/types/team.ts | 2 + src/shared/types/terminal.ts | 49 +++ 29 files changed, 1448 insertions(+), 213 deletions(-) create mode 100644 src/main/ipc/terminal.ts create mode 100644 src/main/services/infrastructure/PtyTerminalService.ts create mode 100644 src/main/services/team/HunkSnippetMatcher.ts create mode 100644 src/main/services/team/UnifiedLineCounter.ts create mode 100644 src/renderer/components/terminal/EmbeddedTerminal.tsx create mode 100644 src/renderer/components/terminal/TerminalModal.tsx create mode 100644 src/shared/types/terminal.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 87083335..dc573831 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,6 +10,10 @@ import type { Plugin } from 'vite' const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')) const prodDeps = Object.keys(pkg.dependencies || {}) +// node-pty is a native addon that cannot be bundled by Rollup. +// It must remain external and be loaded at runtime via require(). +const bundledDeps = prodDeps.filter(d => d !== 'node-pty') + // Rollup plugin: stub out native .node addon imports with empty modules. // ssh2 and cpu-features use optional native bindings that can't be bundled, // but they have pure JS fallbacks when the native module isn't available. @@ -32,7 +36,7 @@ export default defineConfig({ main: { plugins: [ externalizeDepsPlugin({ - exclude: prodDeps + exclude: bundledDeps }), nativeModuleStub() ], diff --git a/package.json b/package.json index 60ed86fb..0a9bee34 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "standalone": "tsx src/main/standalone.ts", "standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts", "standalone:start": "node dist-standalone/index.cjs", - "prepare": "husky" + "prepare": "husky", + "postinstall": "electron-rebuild -f -o node-pty" }, "lint-staged": { "src/**/*.{ts,tsx,js,jsx}": [ @@ -99,6 +100,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-virtual": "^3.10.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -111,6 +114,7 @@ "lucide-react": "^0.562.0", "mdast-util-to-hast": "^13.2.1", "node-diff3": "^3.2.0", + "node-pty": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -125,6 +129,7 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@electron/rebuild": "^4.0.3", "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", "@eslint/js": "^9.39.2", "@tailwindcss/typography": "^0.5.19", @@ -181,7 +186,8 @@ ], "asar": true, "asarUnpack": [ - "out/renderer/**" + "out/renderer/**", + "**/node_modules/node-pty/build/Release/**" ], "extraResources": [ { @@ -240,5 +246,10 @@ } ] }, - "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" + "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501", + "pnpm": { + "onlyBuiltDependencies": [ + "node-pty" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffeea6a9..2ecedb1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,12 @@ importers: '@tanstack/react-virtual': specifier: ^3.10.8 version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -161,6 +167,9 @@ importers: node-diff3: specifier: ^3.2.0 version: 3.2.0 + node-pty: + specifier: ^1.1.0 + version: 1.1.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -198,6 +207,9 @@ importers: specifier: ^4.5.0 version: 4.5.7(@types/react@18.3.27)(react@18.3.1) devDependencies: + '@electron/rebuild': + specifier: ^4.0.3 + version: 4.0.3 '@eslint-community/eslint-plugin-eslint-comments': specifier: ^4.6.0 version: 4.6.0(eslint@9.39.2(jiti@1.21.7)) @@ -586,6 +598,11 @@ packages: engines: {node: '>=12.13.0'} hasBin: true + '@electron/rebuild@4.0.3': + resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + engines: {node: '>=22.12.0'} + hasBin: true + '@electron/universal@1.5.1': resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==} engines: {node: '>=8.6'} @@ -1017,6 +1034,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -1128,10 +1149,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@npmcli/agent@3.0.0': + resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} + engines: {node: ^18.17.0 || >=20.5.0} + '@npmcli/fs@2.1.2': resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + '@npmcli/fs@4.0.0': + resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} + engines: {node: ^18.17.0 || >=20.5.0} + '@npmcli/move-file@2.0.1': resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2101,9 +2130,19 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -2442,6 +2481,10 @@ packages: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + cacache@19.0.1: + resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} + engines: {node: ^18.17.0 || >=20.5.0} + cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -2508,6 +2551,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chromium-pickle-js@0.2.0: resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} @@ -3239,6 +3286,10 @@ packages: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3686,6 +3737,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -3920,6 +3975,10 @@ packages: resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + make-fetch-happen@14.0.3: + resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} + engines: {node: ^18.17.0 || >=20.5.0} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -4128,10 +4187,18 @@ packages: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + minipass-fetch@2.1.2: resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + minipass-fetch@4.0.1: + resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} + engines: {node: ^18.17.0 || >=20.5.0} + minipass-flush@1.0.5: resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} engines: {node: '>= 8'} @@ -4160,6 +4227,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -4195,6 +4266,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -4202,9 +4277,16 @@ packages: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} + node-abi@4.26.0: + resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==} + engines: {node: '>=22.12.0'} + node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -4212,11 +4294,19 @@ packages: resolution: {integrity: sha512-vLh2xJFSyniBLYDEDbXKqD32fQ5vAxmYT4hco8t0EHQ4CQ4BDHhshi7kdvDc6Y1MwGSi1Mhl4unUukPbCayZdw==} engines: {bun: '>=1.3.0', node: '>=18'} + node-gyp@11.5.0: + resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + node-gyp@9.4.1: resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} engines: {node: ^12.13 || ^14.13 || >=16} hasBin: true + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -4225,6 +4315,11 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4320,6 +4415,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -4524,6 +4623,10 @@ packages: engines: {node: '>=14'} hasBin: true + proc-log@5.0.0: + resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} + engines: {node: ^18.17.0 || >=20.5.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -4910,6 +5013,10 @@ packages: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + socks@2.8.7: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -4945,6 +5052,10 @@ packages: resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} engines: {node: '>=10.16.0'} + ssri@12.0.0: + resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} + engines: {node: ^18.17.0 || >=20.5.0} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -5094,6 +5205,10 @@ packages: engines: {node: '>=10'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + tar@7.5.9: + resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} + engines: {node: '>=18'} + temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -5254,10 +5369,18 @@ packages: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unique-filename@4.0.0: + resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} + engines: {node: ^18.17.0 || >=20.5.0} + unique-slug@3.0.0: resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unique-slug@5.0.0: + resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} + engines: {node: ^18.17.0 || >=20.5.0} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -5436,6 +5559,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -5480,6 +5608,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -6040,6 +6172,24 @@ snapshots: - bluebird - supports-color + '@electron/rebuild@4.0.3': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + detect-libc: 2.1.2 + got: 11.8.6 + graceful-fs: 4.2.11 + node-abi: 4.26.0 + node-api-version: 0.2.1 + node-gyp: 11.5.0 + ora: 5.4.1 + read-binary-file-arch: 1.0.6 + semver: 7.7.4 + tar: 7.5.9 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + '@electron/universal@1.5.1': dependencies: '@electron/asar': 3.4.1 @@ -6371,6 +6521,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@istanbuljs/schema@0.1.3': {} '@jridgewell/gen-mapping@0.3.13': @@ -6538,10 +6692,24 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@npmcli/agent@3.0.0': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 10.4.3 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.7.3 + semver: 7.7.4 + + '@npmcli/fs@4.0.0': + dependencies: + semver: 7.7.4 '@npmcli/move-file@2.0.1': dependencies: @@ -7454,8 +7622,14 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + abbrev@1.1.1: {} + abbrev@3.0.1: {} + abstract-logging@2.0.1: {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -7941,6 +8115,21 @@ snapshots: transitivePeerDependencies: - bluebird + cacache@19.0.1: + dependencies: + '@npmcli/fs': 4.0.0 + fs-minipass: 3.0.3 + glob: 10.5.0 + lru-cache: 10.4.3 + minipass: 7.1.2 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 7.0.4 + ssri: 12.0.0 + tar: 7.5.9 + unique-filename: 4.0.0 + cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -8015,6 +8204,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chromium-pickle-js@0.2.0: {} ci-info@3.9.0: {} @@ -9003,6 +9194,10 @@ snapshots: dependencies: minipass: 3.3.6 + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.2 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -9496,6 +9691,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -9764,6 +9961,22 @@ snapshots: - bluebird - supports-color + make-fetch-happen@14.0.3: + dependencies: + '@npmcli/agent': 3.0.0 + cacache: 19.0.1 + http-cache-semantics: 4.2.0 + minipass: 7.1.2 + minipass-fetch: 4.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 5.0.0 + promise-retry: 2.0.1 + ssri: 12.0.0 + transitivePeerDependencies: + - supports-color + markdown-table@3.0.4: {} matcher@3.0.0: @@ -10168,6 +10381,10 @@ snapshots: dependencies: minipass: 3.3.6 + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.2 + minipass-fetch@2.1.2: dependencies: minipass: 3.3.6 @@ -10176,6 +10393,14 @@ snapshots: optionalDependencies: encoding: 0.1.13 + minipass-fetch@4.0.1: + dependencies: + minipass: 7.1.2 + minipass-sized: 1.0.3 + minizlib: 3.1.0 + optionalDependencies: + encoding: 0.1.13 + minipass-flush@1.0.5: dependencies: minipass: 3.3.6 @@ -10201,6 +10426,10 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.1.0: + dependencies: + minipass: 7.1.2 + mkdirp@1.0.4: {} ms@2.1.3: {} @@ -10224,21 +10453,44 @@ snapshots: negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} node-abi@3.87.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 + + node-abi@4.26.0: + dependencies: + semver: 7.7.4 node-addon-api@1.7.2: optional: true + node-addon-api@7.1.1: {} + node-api-version@0.2.1: dependencies: - semver: 7.7.3 + semver: 7.7.4 node-diff3@3.2.0: {} + node-gyp@11.5.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + make-fetch-happen: 14.0.3 + nopt: 8.1.0 + proc-log: 5.0.0 + semver: 7.7.4 + tar: 7.5.9 + tinyglobby: 0.2.15 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + node-gyp@9.4.1: dependencies: env-paths: 2.2.1 @@ -10249,19 +10501,27 @@ snapshots: nopt: 6.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.7.3 + semver: 7.7.4 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: - bluebird - supports-color + node-pty@1.1.0: + dependencies: + node-addon-api: 7.1.1 + node-releases@2.0.27: {} nopt@6.0.0: dependencies: abbrev: 1.1.1 + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -10395,6 +10655,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-map@7.0.4: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -10529,6 +10791,8 @@ snapshots: prettier@3.8.1: {} + proc-log@5.0.0: {} + process-nextick-args@2.0.1: {} process-warning@4.0.1: {} @@ -11002,6 +11266,14 @@ snapshots: transitivePeerDependencies: - supports-color + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + socks@2.8.7: dependencies: ip-address: 10.1.0 @@ -11037,6 +11309,10 @@ snapshots: cpu-features: 0.0.10 nan: 2.25.0 + ssri@12.0.0: + dependencies: + minipass: 7.1.2 + ssri@9.0.1: dependencies: minipass: 3.3.6 @@ -11245,6 +11521,14 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.5.9: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 + temp-file@3.4.0: dependencies: async-exit-hook: 2.0.1 @@ -11422,10 +11706,18 @@ snapshots: dependencies: unique-slug: 3.0.0 + unique-filename@4.0.0: + dependencies: + unique-slug: 5.0.0 + unique-slug@3.0.0: dependencies: imurmurhash: 0.1.4 + unique-slug@5.0.0: + dependencies: + imurmurhash: 0.1.4 + unist-util-find-after@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -11655,6 +11947,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@5.0.0: + dependencies: + isexe: 3.1.5 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -11696,6 +11992,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@2.8.2: {} yargs-parser@21.1.1: {} diff --git a/src/main/index.ts b/src/main/index.ts index 2cd60d61..4547d15c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -43,6 +43,7 @@ import { LocalFileSystemProvider, MemberStatsComputer, NotificationManager, + PtyTerminalService, ServiceContext, ServiceContextRegistry, SshConnectionManager, @@ -170,6 +171,7 @@ let sshConnectionManager: SshConnectionManager; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; +let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; // File watcher event cleanup functions @@ -422,6 +424,7 @@ function initializeServices(): void { // Initialize updater and CLI installer services updaterService = new UpdaterService(); cliInstallerService = new CliInstallerService(); + ptyTerminalService = new PtyTerminalService(); teamDataService = new TeamDataService(); teamProvisioningService = new TeamProvisioningService(); const teamMemberLogsFinder = new TeamMemberLogsFinder(); @@ -474,7 +477,8 @@ function initializeServices(): void { fileContentResolver, reviewApplier, gitDiffFallback, - cliInstallerService + cliInstallerService, + ptyTerminalService ); // Forward SSH state changes to renderer and HTTP SSE clients @@ -568,6 +572,11 @@ function shutdownServices(): void { sshConnectionManager.dispose(); } + // Kill all PTY processes + if (ptyTerminalService) { + ptyTerminalService.killAll(); + } + // Remove IPC handlers removeIpcHandlers(); @@ -719,6 +728,9 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(null); } + if (ptyTerminalService) { + ptyTerminalService.setMainWindow(null); + } }); // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) @@ -737,6 +749,9 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(mainWindow); } + if (ptyTerminalService) { + ptyTerminalService.setMainWindow(mainWindow); + } logger.info('Main window created'); } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 108d0855..711c6b7d 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -55,6 +55,11 @@ import { removeSubagentHandlers, } from './subagents'; import { initializeTeamHandlers, registerTeamHandlers, removeTeamHandlers } from './teams'; +import { + initializeTerminalHandlers, + registerTerminalHandlers, + removeTerminalHandlers, +} from './terminal'; import { initializeUpdaterHandlers, registerUpdaterHandlers, @@ -70,6 +75,7 @@ import type { FileContentResolver, GitDiffFallback, MemberStatsComputer, + PtyTerminalService, ReviewApplierService, ServiceContext, ServiceContextRegistry, @@ -105,7 +111,8 @@ export function initializeIpcHandlers( fileContentResolver?: FileContentResolver, reviewApplier?: ReviewApplierService, gitDiffFallback?: GitDiffFallback, - cliInstaller?: CliInstallerService + cliInstaller?: CliInstallerService, + ptyTerminal?: PtyTerminalService ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -133,6 +140,9 @@ export function initializeIpcHandlers( if (cliInstaller) { initializeCliInstallerHandlers(cliInstaller); } + if (ptyTerminal) { + initializeTerminalHandlers(ptyTerminal); + } if (changeExtractor) { initializeReviewHandlers({ extractor: changeExtractor, @@ -160,6 +170,9 @@ export function initializeIpcHandlers( if (cliInstaller) { registerCliInstallerHandlers(ipcMain); } + if (ptyTerminal) { + registerTerminalHandlers(ipcMain); + } if (httpServerDeps) { registerHttpServerHandlers(ipcMain); } @@ -187,6 +200,7 @@ export function removeIpcHandlers(): void { removeReviewHandlers(ipcMain); removeWindowHandlers(ipcMain); removeCliInstallerHandlers(ipcMain); + removeTerminalHandlers(ipcMain); removeHttpServerHandlers(ipcMain); logger.info('All handlers removed'); diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts new file mode 100644 index 00000000..1d2a69b7 --- /dev/null +++ b/src/main/ipc/terminal.ts @@ -0,0 +1,84 @@ +/** + * IPC Handlers for Embedded Terminal Operations. + * + * Handlers: + * - terminal:spawn: Spawn a new PTY process (returns pty ID) + * - terminal:write: Write data to PTY stdin (fire-and-forget) + * - terminal:resize: Resize PTY terminal (fire-and-forget) + * - terminal:kill: Kill PTY process (fire-and-forget) + * - terminal:data: PTY output events (main → renderer, not a handler) + * - terminal:exit: PTY exit events (main → renderer, not a handler) + */ + +import { + TERMINAL_KILL, + TERMINAL_RESIZE, + TERMINAL_SPAWN, + TERMINAL_WRITE, + // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload +} from '@preload/constants/ipcChannels'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import type { PtyTerminalService } from '../services'; +import type { IpcResult } from '@shared/types'; +import type { PtySpawnOptions } from '@shared/types/terminal'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('IPC:terminal'); + +let service: PtyTerminalService; + +/** + * Initializes terminal handlers with the service instance. + */ +export function initializeTerminalHandlers(terminalService: PtyTerminalService): void { + service = terminalService; +} + +/** + * Registers all terminal IPC handlers. + */ +export function registerTerminalHandlers(ipcMain: IpcMain): void { + // spawn uses handle (needs response with pty ID) + ipcMain.handle(TERMINAL_SPAWN, handleSpawn); + + // write, resize, kill are fire-and-forget (hot path, latency-sensitive) + ipcMain.on(TERMINAL_WRITE, (_event, ptyId: string, data: string) => service.write(ptyId, data)); + ipcMain.on(TERMINAL_RESIZE, (_event, ptyId: string, cols: number, rows: number) => + service.resize(ptyId, cols, rows) + ); + ipcMain.on(TERMINAL_KILL, (_event, ptyId: string) => service.kill(ptyId)); + + logger.info('Terminal handlers registered'); +} + +/** + * Removes all terminal IPC handlers. + */ +export function removeTerminalHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler(TERMINAL_SPAWN); + ipcMain.removeAllListeners(TERMINAL_WRITE); + ipcMain.removeAllListeners(TERMINAL_RESIZE); + ipcMain.removeAllListeners(TERMINAL_KILL); + + logger.info('Terminal handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +async function handleSpawn( + _event: IpcMainInvokeEvent, + options?: PtySpawnOptions +): Promise> { + try { + const id = service.spawn(options); + return { success: true, data: id }; + } catch (error) { + const msg = getErrorMessage(error); + logger.error('Error in terminal:spawn:', msg); + return { success: false, error: msg }; + } +} diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index abfd749b..fc211670 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -191,6 +191,8 @@ export class CliInstallerService { binaryPath: null, latestVersion: null, updateAvailable: false, + authLoggedIn: false, + authMethod: null, }; const binaryPath = await ClaudeBinaryResolver.resolve(); @@ -209,6 +211,22 @@ export class CliInstallerService { } catch (err) { logger.warn('Failed to get CLI version:', getErrorMessage(err)); } + + // Check auth status + try { + const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], { + timeout: VERSION_TIMEOUT_MS, + }); + const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string }; + result.authLoggedIn = auth.loggedIn === true; + result.authMethod = auth.authMethod ?? null; + logger.info( + `Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` + ); + } catch (err) { + logger.warn('Failed to check auth status:', getErrorMessage(err)); + result.authLoggedIn = false; + } } try { diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts new file mode 100644 index 00000000..f03bd429 --- /dev/null +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -0,0 +1,112 @@ +/** + * PtyTerminalService — manages node-pty terminal instances. + * + * Provides PTY spawning, IO, and lifecycle management for the embedded terminal. + * Events (data, exit) are forwarded to the renderer via mainWindow.webContents.send(). + */ + +import crypto from 'node:crypto'; +import os from 'node:os'; + +// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload +import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; + +import type { PtySpawnOptions } from '@shared/types/terminal'; +import type { BrowserWindow } from 'electron'; + +const logger = createLogger('PtyTerminalService'); + +// Graceful import: node-pty is a native addon that may not be available +// if electron-rebuild was not run or native build tools are missing. +import type { IPty } from 'node-pty'; +import type * as NodePty from 'node-pty'; +type NodePtyModule = typeof NodePty; + +let nodePty: NodePtyModule | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon + nodePty = require('node-pty') as NodePtyModule; +} catch { + logger.warn('node-pty not available — terminal features disabled'); +} + +export class PtyTerminalService { + private ptys = new Map(); + private mainWindow: BrowserWindow | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + /** + * Spawn a new PTY process. + * @returns Unique PTY ID for subsequent write/resize/kill calls. + * @throws If node-pty native module is not available. + */ + spawn(options?: PtySpawnOptions): string { + if (!nodePty) { + throw new Error( + 'Terminal not available: node-pty native module not found. Run: pnpm install' + ); + } + + const id = crypto.randomUUID(); + const shell = + options?.command ?? + (process.platform === 'win32' + ? (process.env.COMSPEC ?? 'powershell.exe') + : (process.env.SHELL ?? '/bin/bash')); + + const pty = nodePty.spawn(shell, options?.args ?? [], { + name: 'xterm-256color', + cols: options?.cols ?? 80, + rows: options?.rows ?? 24, + cwd: options?.cwd ?? os.homedir(), + env: { ...process.env, ...options?.env } as Record, + }); + + pty.onData((data) => this.send(TERMINAL_DATA, id, data)); + pty.onExit(({ exitCode }) => { + this.send(TERMINAL_EXIT, id, exitCode); + this.ptys.delete(id); + }); + + this.ptys.set(id, pty); + logger.info(`PTY spawned: ${id} (${shell})`); + return id; + } + + write(id: string, data: string): void { + this.ptys.get(id)?.write(data); + } + + resize(id: string, cols: number, rows: number): void { + this.ptys.get(id)?.resize(cols, rows); + } + + kill(id: string): void { + const pty = this.ptys.get(id); + if (pty) { + pty.kill(); + this.ptys.delete(id); + logger.info(`PTY killed: ${id}`); + } + } + + /** Kill all PTY processes. Called on app shutdown. */ + killAll(): void { + const count = this.ptys.size; + if (count > 0) { + logger.info(`Killing ${count} PTY processes on shutdown`); + } + this.ptys.forEach((pty) => pty.kill()); + this.ptys.clear(); + } + + private send(channel: string, ...args: unknown[]): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(channel, ...args); + } + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 04ae52e2..0d69b3c2 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -24,6 +24,7 @@ export * from './FileWatcher'; export * from './HttpServer'; export * from './LocalFileSystemProvider'; export * from './NotificationManager'; +export * from './PtyTerminalService'; export * from './ServiceContext'; export * from './ServiceContextRegistry'; export * from './SshConfigParser'; diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 360c60bd..ba58bdc6 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -1,10 +1,10 @@ import { createLogger } from '@shared/utils/logger'; -import { diffLines } from 'diff'; import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; +import { countLineChanges } from './UnifiedLineCounter'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; @@ -37,7 +37,7 @@ interface LogFileRef { export class ChangeExtractorService { private cache = new Map(); - private readonly CACHE_TTL = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk + private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk constructor( private readonly logsFinder: TeamMemberLogsFinder, @@ -96,7 +96,7 @@ export class ChangeExtractorService { this.cache.set(cacheKey, { data: result, mtime: latestMtime, - expiresAt: Date.now() + this.CACHE_TTL, + expiresAt: Date.now() + this.cacheTtl, }); return result; @@ -173,6 +173,26 @@ export class ChangeExtractorService { } } + /** + * Compute a context hash from old/newString for reliable hunk↔snippet matching. + * Uses first+last 3 lines of both strings as a fingerprint. + */ + private computeContextHash(oldString: string, newString: string): string { + const take3 = (s: string): string => { + const lines = s.split('\n'); + const head = lines.slice(0, 3).join('\n'); + const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; + return `${head}|${tail}`; + }; + const raw = `${take3(oldString)}::${take3(newString)}`; + // Simple hash: DJB2 variant (fast, no crypto needed) + let hash = 5381; + for (let i = 0; i < raw.length; i++) { + hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); + } + /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ private async parseJSONLFile(filePath: string): Promise { // Сначала считываем все записи в память для двух проходов @@ -237,16 +257,16 @@ export class ChangeExtractorService { const isError = erroredIds.has(toolUseId); if (toolName === 'Edit') { - const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const path = typeof input.file_path === 'string' ? input.file_path : ''; const oldString = typeof input.old_string === 'string' ? input.old_string : ''; const newString = typeof input.new_string === 'string' ? input.new_string : ''; const replaceAll = input.replace_all === true; - if (filePath_) { - seenFiles.add(filePath_); + if (path) { + seenFiles.add(path); snippets.push({ toolUseId, - filePath: filePath_, + filePath: path, toolName: 'Edit', type: 'edit', oldString, @@ -254,18 +274,19 @@ export class ChangeExtractorService { replaceAll, timestamp, isError, + contextHash: this.computeContextHash(oldString, newString), }); } } else if (toolName === 'Write') { - const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const path = typeof input.file_path === 'string' ? input.file_path : ''; const writeContent = typeof input.content === 'string' ? input.content : ''; - if (filePath_) { - const isNew = !seenFiles.has(filePath_); - seenFiles.add(filePath_); + if (path) { + const isNew = !seenFiles.has(path); + seenFiles.add(path); snippets.push({ toolUseId, - filePath: filePath_, + filePath: path, toolName: 'Write', type: isNew ? 'write-new' : 'write-update', oldString: '', @@ -273,14 +294,15 @@ export class ChangeExtractorService { replaceAll: false, timestamp, isError, + contextHash: this.computeContextHash('', writeContent), }); } } else if (toolName === 'MultiEdit') { - const filePath_ = typeof input.file_path === 'string' ? input.file_path : ''; + const path = typeof input.file_path === 'string' ? input.file_path : ''; const edits = Array.isArray(input.edits) ? input.edits : []; - if (filePath_) { - seenFiles.add(filePath_); + if (path) { + seenFiles.add(path); for (const edit of edits) { if (!edit || typeof edit !== 'object') continue; const editObj = edit as Record; @@ -288,7 +310,7 @@ export class ChangeExtractorService { const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; snippets.push({ toolUseId, - filePath: filePath_, + filePath: path, toolName: 'MultiEdit', type: 'multi-edit', oldString, @@ -296,6 +318,7 @@ export class ChangeExtractorService { replaceAll: false, timestamp, isError, + contextHash: this.computeContextHash(oldString, newString), }); } } @@ -388,7 +411,7 @@ export class ChangeExtractorService { let totalRemoved = 0; for (const s of data.snippets) { if (s.isError) continue; - const { added, removed } = this.countLines(s.oldString, s.newString); + const { added, removed } = countLineChanges(s.oldString, s.newString); totalAdded += added; totalRemoved += removed; } @@ -418,15 +441,18 @@ export class ChangeExtractorService { private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { const events: FileEditEvent[] = snippets .filter((s) => !s.isError) - .map((s, idx) => ({ - toolUseId: s.toolUseId, - toolName: s.toolName as FileEditEvent['toolName'], - timestamp: s.timestamp, - summary: this.generateEditSummary(s), - linesAdded: Math.max(0, s.newString.split('\n').length - s.oldString.split('\n').length), - linesRemoved: Math.max(0, s.oldString.split('\n').length - s.newString.split('\n').length), - snippetIndex: idx, - })); + .map((s, idx) => { + const { added, removed } = countLineChanges(s.oldString, s.newString); + return { + toolUseId: s.toolUseId, + toolName: s.toolName as FileEditEvent['toolName'], + timestamp: s.timestamp, + summary: this.generateEditSummary(s), + linesAdded: added, + linesRemoved: removed, + snippetIndex: idx, + }; + }); const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t)); const durationMs = @@ -442,16 +468,14 @@ export class ChangeExtractorService { case 'write-update': return 'Wrote full file content'; case 'multi-edit': { - const lines = snippet.oldString.split('\n').length; - return `Multi-edit (${lines} line${lines !== 1 ? 's' : ''})`; + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + const total = added + removed; + return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; } case 'edit': { - const added = snippet.newString.split('\n').length; - const removed = snippet.oldString.split('\n').length; - if (removed === 0 || snippet.oldString === '') - return `Added ${added} line${added !== 1 ? 's' : ''}`; - if (added === 0 || snippet.newString === '') - return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; + if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; return `Changed ${removed} → ${added} lines`; } default: @@ -459,19 +483,6 @@ export class ChangeExtractorService { } } - /** Подсчёт добавленных/удалённых строк через diff */ - private countLines(oldStr: string, newStr: string): { added: number; removed: number } { - if (!oldStr && !newStr) return { added: 0, removed: 0 }; - const changes = diffLines(oldStr, newStr); - let added = 0; - let removed = 0; - for (const c of changes) { - if (c.added) added += c.count ?? 0; - if (c.removed) removed += c.count ?? 0; - } - return { added, removed }; - } - /** Проверить, содержит ли путь к файлу один из sessionId */ private pathMatchesAnySession(filePath: string, sessionIds: Set): boolean { for (const sessionId of sessionIds) { diff --git a/src/main/services/team/HunkSnippetMatcher.ts b/src/main/services/team/HunkSnippetMatcher.ts new file mode 100644 index 00000000..c2a28cfc --- /dev/null +++ b/src/main/services/team/HunkSnippetMatcher.ts @@ -0,0 +1,155 @@ +import { structuredPatch } from 'diff'; + +import type { SnippetDiff } from '@shared/types'; + +/** + * Reliable hunk↔snippet matcher using content overlap analysis. + * + * Uses bidirectional substring matching between hunk added/removed lines + * and snippet newString/oldString to determine which snippets correspond + * to which diff hunks. + * + * Replaces the previous 1:1 hunkIndex→snippetIndex assumption. + */ +export class HunkSnippetMatcher { + /** + * Match hunk indices to their corresponding snippets. + * Returns a Map where each hunk index maps to the set of matching snippet indices. + * + * @param snippets — MUST be pre-filtered (no isError entries). + * Returned indices are relative to this array. + */ + matchHunksToSnippets( + original: string, + modified: string, + hunkIndices: number[], + snippets: SnippetDiff[] + ): Map> { + if (snippets.length === 0) return new Map(); + + const patch = structuredPatch('file', 'file', original, modified); + if (!patch.hunks || patch.hunks.length === 0) return new Map(); + + const mapping = new Map>(); + + for (const hunkIdx of hunkIndices) { + if (hunkIdx < 0 || hunkIdx >= patch.hunks.length) continue; + const hunk = patch.hunks[hunkIdx]; + const snippetSet = new Set(); + + // Extract added/removed content from hunk + const addedLines = hunk.lines.filter((l) => l.startsWith('+')).map((l) => l.slice(1)); + const removedLines = hunk.lines.filter((l) => l.startsWith('-')).map((l) => l.slice(1)); + const addedContent = addedLines.join('\n'); + const removedContent = removedLines.join('\n'); + + for (let sIdx = 0; sIdx < snippets.length; sIdx++) { + const snippet = snippets[sIdx]; + + // Content overlap: check if snippet's strings appear in hunk's diff content + if (this.hasContentOverlap(snippet, addedContent, removedContent)) { + snippetSet.add(sIdx); + } + } + + mapping.set(hunkIdx, snippetSet); + } + + return mapping; + } + + /** + * Find the correct position of a snippet's newString in the content, + * disambiguating when multiple occurrences exist. + */ + findSnippetPosition(snippet: SnippetDiff, content: string): number { + const { newString, oldString } = snippet; + if (!newString) return -1; // Deletion — can't find empty string reliably + + const firstPos = content.indexOf(newString); + if (firstPos === -1) return -1; + + // Fast path: only one occurrence — no ambiguity + const lastPos = content.lastIndexOf(newString); + if (firstPos === lastPos) return firstPos; + + // Multiple occurrences — collect all positions + const positions: number[] = []; + let searchStart = 0; + while (true) { + const pos = content.indexOf(newString, searchStart); + if (pos === -1) break; + positions.push(pos); + searchStart = pos + 1; + } + + // Disambiguate using oldString context + if (oldString) { + const oldTokens = oldString + .split(/\s+/) + .filter((t) => t.length > 3) + .slice(0, 20); // Limit tokens to prevent excessive scanning + + if (oldTokens.length > 0) { + let bestPos = firstPos; + let bestScore = 0; + + for (const pos of positions) { + const nearbyStart = Math.max(0, pos - 500); + const nearbyEnd = Math.min(content.length, pos + newString.length + 500); + const nearby = content.substring(nearbyStart, nearbyEnd); + + const matchScore = oldTokens.filter((t) => nearby.includes(t)).length; + if (matchScore > bestScore) { + bestScore = matchScore; + bestPos = pos; + } + } + + return bestPos; + } + } + + return firstPos; + } + + // ── Private helpers ── + + /** + * Check if a snippet's content overlaps with hunk's added/removed content. + */ + private hasContentOverlap( + snippet: SnippetDiff, + hunkAddedContent: string, + hunkRemovedContent: string + ): boolean { + // Skip empty snippets + if (!snippet.newString && !snippet.oldString) return false; + + if (snippet.type === 'write-new' || snippet.type === 'write-update') { + // For Write: check if hunk's added content is a substring of snippet's newString + if (snippet.newString && hunkAddedContent) { + return snippet.newString.includes(hunkAddedContent); + } + return false; + } + + // For Edit/MultiEdit: check bidirectional overlap + const matchesNew = snippet.newString + ? hunkAddedContent.includes(snippet.newString) || snippet.newString.includes(hunkAddedContent) + : false; + + const matchesOld = snippet.oldString + ? hunkRemovedContent.includes(snippet.oldString) || + snippet.oldString.includes(hunkRemovedContent) + : false; + + // Both directions match = high confidence + if (matchesNew && matchesOld) return true; + + // Single direction match = acceptable for Edit + if (matchesNew || matchesOld) return true; + + return false; + } +} diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index 11e5f90b..5b8b28ed 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -3,6 +3,7 @@ import { createReadStream } from 'fs'; import * as readline from 'readline'; import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import { countLineChanges } from './UnifiedLineCounter'; import type { FileLineStats, MemberFullStats } from '@shared/types'; @@ -117,6 +118,9 @@ export class MemberStatsComputer { let firstTimestamp: string | null = null; let lastTimestamp: string | null = null; + // Track last known content per file for accurate Write/NotebookEdit diffs + const fileLastContent = new Map(); + const trackFile = (fp: string): void => { if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp); }; @@ -186,41 +190,73 @@ export class MemberStatsComputer { trackFile(input.path); } - // Count lines for Edit + // Count lines for Edit (using semantic diff for accuracy) if (toolName === 'Edit') { + const editPath = typeof input.file_path === 'string' ? input.file_path : ''; const oldStr = typeof input.old_string === 'string' ? input.old_string : ''; const newStr = typeof input.new_string === 'string' ? input.new_string : ''; - const oldLines = oldStr ? oldStr.split('\n').length : 0; - const newLines = newStr ? newStr.split('\n').length : 0; - const fileAdded = newLines > oldLines ? newLines - oldLines : 0; - const fileRemoved = oldLines > newLines ? oldLines - newLines : 0; + const replaceAll = input.replace_all === true; + const { added: fileAdded, removed: fileRemoved } = countLineChanges( + oldStr, + newStr + ); linesAdded += fileAdded; linesRemoved += fileRemoved; - if (typeof input.file_path === 'string') { - addFileLines(input.file_path, fileAdded, fileRemoved); - } - } - - // Count lines for Write - if (toolName === 'Write') { - const writeContent = typeof input.content === 'string' ? input.content : ''; - if (writeContent) { - const fileAdded = writeContent.split('\n').length; - linesAdded += fileAdded; - if (typeof input.file_path === 'string') { - addFileLines(input.file_path, fileAdded, 0); + if (editPath) { + addFileLines(editPath, fileAdded, fileRemoved); + // Update fileLastContent so subsequent Writes diff against correct state + const prev = fileLastContent.get(editPath); + if (prev !== undefined && oldStr) { + if (replaceAll) { + fileLastContent.set(editPath, prev.split(oldStr).join(newStr)); + } else { + const idx = prev.indexOf(oldStr); + if (idx !== -1) { + fileLastContent.set( + editPath, + prev.substring(0, idx) + newStr + prev.substring(idx + oldStr.length) + ); + } + } } } } - // Count lines for NotebookEdit + // Count lines for Write (track previous content for accurate diff) + if (toolName === 'Write') { + const writeContent = typeof input.content === 'string' ? input.content : ''; + const writePath = typeof input.file_path === 'string' ? input.file_path : ''; + if (writeContent) { + const prevContent = fileLastContent.get(writePath) ?? ''; + const { added: fileAdded, removed: fileRemoved } = countLineChanges( + prevContent, + writeContent + ); + if (writePath) fileLastContent.set(writePath, writeContent); + linesAdded += fileAdded; + linesRemoved += fileRemoved; + if (writePath) { + addFileLines(writePath, fileAdded, fileRemoved); + } + } + } + + // Count lines for NotebookEdit (semantic diff) if (toolName === 'NotebookEdit') { const src = typeof input.new_source === 'string' ? input.new_source : ''; if (src) { - const fileAdded = src.split('\n').length; + const nbPath = + typeof input.notebook_path === 'string' ? input.notebook_path : ''; + const prevContent = fileLastContent.get(nbPath) ?? ''; + const { added: fileAdded, removed: fileRemoved } = countLineChanges( + prevContent, + src + ); + if (nbPath) fileLastContent.set(nbPath, src); linesAdded += fileAdded; - if (typeof input.notebook_path === 'string') { - addFileLines(input.notebook_path, fileAdded, 0); + linesRemoved += fileRemoved; + if (nbPath) { + addFileLines(nbPath, fileAdded, fileRemoved); } } if (typeof input.notebook_path === 'string') { diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 6f963cae..1b47be59 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -3,6 +3,8 @@ import { applyPatch, structuredPatch } from 'diff'; import { readFile, writeFile } from 'fs/promises'; import { diff3Merge } from 'node-diff3'; +import { HunkSnippetMatcher } from './HunkSnippetMatcher'; + import type { ApplyReviewRequest, ApplyReviewResult, @@ -26,6 +28,8 @@ const logger = createLogger('Service:ReviewApplierService'); * - Batch review application */ export class ReviewApplierService { + private readonly matcher = new HunkSnippetMatcher(); + /** * Check if the file on disk has been modified since the review was computed. * Compares current disk content against the expected modified content. @@ -68,7 +72,7 @@ export class ReviewApplierService { snippets: SnippetDiff[] ): Promise { // Try snippet-level reverse first (most accurate) - const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets); + const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets); if (snippetResult) { try { await writeFile(filePath, snippetResult.newContent, 'utf8'); @@ -199,7 +203,7 @@ export class ReviewApplierService { snippets: SnippetDiff[] ): Promise<{ preview: string; hasConflicts: boolean }> { // Try snippet-level reverse - const snippetResult = this.trySnippetLevelReject(modified, hunkIndices, snippets); + const snippetResult = this.trySnippetLevelReject(original, modified, hunkIndices, snippets); if (snippetResult) { return { preview: snippetResult.newContent, hasConflicts: false }; } @@ -323,10 +327,11 @@ export class ReviewApplierService { /** * Snippet-level rejection: reverse specific snippets by position (most accurate). * - * Maps hunk indices to snippet indices, then reverses each snippet's edit - * in reverse positional order to avoid index shift. + * Uses HunkSnippetMatcher with content overlap analysis to map + * hunk indices → snippet indices, then reverses matched snippets. */ private trySnippetLevelReject( + original: string, modified: string, hunkIndices: number[], snippets: SnippetDiff[] @@ -334,11 +339,21 @@ export class ReviewApplierService { const validSnippets = snippets.filter((s) => !s.isError); if (validSnippets.length === 0) return null; - // Map hunk indices to snippet indices. - // The structured patch hunks roughly correspond to groups of consecutive edit snippets, - // but a simple approach: use hunkIndices as snippet indices (Phase 2 assumption). - const snippetsToReject = hunkIndices - .filter((idx) => idx >= 0 && idx < validSnippets.length) + // Pass pre-filtered snippets — matcher returns indices relative to this array + const hunkToSnippets = this.matcher.matchHunksToSnippets( + original, + modified, + hunkIndices, + validSnippets + ); + + // Collect all unique snippet indices to reject + const snippetIndices = new Set(); + for (const indices of hunkToSnippets.values()) { + indices.forEach((idx) => snippetIndices.add(idx)); + } + + const snippetsToReject = Array.from(snippetIndices) .map((idx) => validSnippets[idx]) .filter(Boolean); @@ -346,18 +361,17 @@ export class ReviewApplierService { let content = modified; - // Sort by position in file descending to avoid index shift when replacing - // We find each snippet's newString position and sort by that + // Find positions using disambiguation and sort descending for safe replacement const positioned = snippetsToReject .map((snippet) => { - const pos = content.indexOf(snippet.newString); + const pos = this.matcher.findSnippetPosition(snippet, content); return { snippet, pos }; }) .filter((item) => item.pos !== -1) .sort((a, b) => b.pos - a.pos); if (positioned.length !== snippetsToReject.length) { - // Some snippets' newStrings not found in current content — can't do snippet-level + // Some snippets' newStrings not found — can't do snippet-level return null; } diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 2a283690..96406b62 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -94,6 +94,9 @@ export class TeamTaskReader { /* leave undefined */ } + // `satisfies Record` ensures compile-time + // safety: if a field is added to TeamTask but not mapped here, + // TypeScript will error. This prevents silently dropping new fields. const task: TeamTask = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', @@ -126,7 +129,13 @@ export class TeamTaskReader { typeof c.createdAt === 'string' ) : undefined, - }; + needsClarification: (['lead', 'user'] as const).includes( + parsed.needsClarification as 'lead' | 'user' + ) + ? (parsed.needsClarification as 'lead' | 'user') + : undefined, + deletedAt: undefined, // deleted tasks are filtered out below + } satisfies Record; if (task.status === 'deleted') { continue; } diff --git a/src/main/services/team/UnifiedLineCounter.ts b/src/main/services/team/UnifiedLineCounter.ts new file mode 100644 index 00000000..1087f694 --- /dev/null +++ b/src/main/services/team/UnifiedLineCounter.ts @@ -0,0 +1,25 @@ +import { diffLines } from 'diff'; + +/** + * Unified line counting utility using semantic diff. + * Ensures consistent +/- line counts across all services + * (MemberStatsComputer, ChangeExtractorService, FileContentResolver). + * + * Uses `diffLines()` from npm `diff` package — the same algorithm + * already used correctly in ChangeExtractorService.countLines() + * and FileContentResolver.getFileContent(). + */ +export function countLineChanges( + oldStr: string, + newStr: string +): { added: number; removed: number } { + if (!oldStr && !newStr) return { added: 0, removed: 0 }; + const changes = diffLines(oldStr, newStr); + let added = 0; + let removed = 0; + for (const c of changes) { + if (c.added) added += c.count ?? 0; + if (c.removed) removed += c.count ?? 0; + } + return { added, removed }; +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 8644e453..e46405df 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -2,6 +2,7 @@ export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; export { FileContentResolver } from './FileContentResolver'; export { GitDiffFallback } from './GitDiffFallback'; +export { HunkSnippetMatcher } from './HunkSnippetMatcher'; export { MemberStatsComputer } from './MemberStatsComputer'; export { ReviewApplierService } from './ReviewApplierService'; export { TaskBoundaryParser } from './TaskBoundaryParser'; @@ -19,3 +20,4 @@ export { TeamProvisioningService } from './TeamProvisioningService'; export { TeamSentMessagesStore } from './TeamSentMessagesStore'; export { TeamTaskReader } from './TeamTaskReader'; export { TeamTaskWriter } from './TeamTaskWriter'; +export { countLineChanges } from './UnifiedLineCounter'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 5e8df42d..19042fdc 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -325,6 +325,28 @@ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; /** CLI installer progress events (main -> renderer) */ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; +// ============================================================================= +// Terminal API Channels +// ============================================================================= + +/** Spawn a new PTY terminal process */ +export const TERMINAL_SPAWN = 'terminal:spawn'; + +/** Write data to PTY stdin (fire-and-forget) */ +export const TERMINAL_WRITE = 'terminal:write'; + +/** Resize PTY terminal (fire-and-forget) */ +export const TERMINAL_RESIZE = 'terminal:resize'; + +/** Kill PTY process (fire-and-forget) */ +export const TERMINAL_KILL = 'terminal:kill'; + +/** PTY data output (main -> renderer) */ +export const TERMINAL_DATA = 'terminal:data'; + +/** PTY process exit (main -> renderer) */ +export const TERMINAL_EXIT = 'terminal:exit'; + // ============================================================================= // Review API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 1ba04287..d3a96086 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,6 +79,12 @@ import { TEAM_UPDATE_MEMBER_ROLE, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, + TERMINAL_DATA, + TERMINAL_EXIT, + TERMINAL_KILL, + TERMINAL_RESIZE, + TERMINAL_SPAWN, + TERMINAL_WRITE, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -173,6 +179,7 @@ import type { UpdateKanbanPatch, WslClaudeRootCandidate, } from '@shared/types'; +import type { PtySpawnOptions } from '@shared/types/terminal'; // ============================================================================= // IPC Result Types and Helpers @@ -880,6 +887,39 @@ const electronAPI: ElectronAPI = { }; }, }, + + // ===== Terminal API ===== + terminal: { + spawn: (options?: PtySpawnOptions) => invokeIpcWithResult(TERMINAL_SPAWN, options), + write: (ptyId: string, data: string) => ipcRenderer.send(TERMINAL_WRITE, ptyId, data), + resize: (ptyId: string, cols: number, rows: number) => + ipcRenderer.send(TERMINAL_RESIZE, ptyId, cols, rows), + kill: (ptyId: string) => ipcRenderer.send(TERMINAL_KILL, ptyId), + onData: (cb: (event: unknown, ptyId: string, data: string) => void): (() => void) => { + ipcRenderer.on( + TERMINAL_DATA, + cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TERMINAL_DATA, + cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + onExit: (cb: (event: unknown, ptyId: string, exitCode: number) => void): (() => void) => { + ipcRenderer.on( + TERMINAL_EXIT, + cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TERMINAL_EXIT, + cb as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6c1ae5e5..aaaececb 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -63,6 +63,7 @@ import type { WslClaudeRootCandidate, } from '@shared/types'; import type { AgentConfig } from '@shared/types/api'; +import type { TerminalAPI } from '@shared/types/terminal'; export class HttpAPIClient implements ElectronAPI { private baseUrl: string; @@ -877,6 +878,8 @@ export class HttpAPIClient implements ElectronAPI { binaryPath: null, latestVersion: null, updateAvailable: false, + authLoggedIn: false, + authMethod: null, }), install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); @@ -885,4 +888,19 @@ export class HttpAPIClient implements ElectronAPI { return () => {}; }, }; + + // --------------------------------------------------------------------------- + // Terminal (not available in browser mode) + // --------------------------------------------------------------------------- + + terminal: TerminalAPI = { + spawn: async (): Promise => { + throw new Error('Terminal not available in browser mode'); + }, + write: () => {}, + resize: () => {}, + kill: () => {}, + onData: (): (() => void) => () => {}, + onExit: (): (() => void) => () => {}, + }; } diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 606be95d..0a731c48 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -7,24 +7,34 @@ * Only rendered in Electron mode. */ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api, isElectronMode } from '@renderer/api'; +import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { formatBytes } from '@renderer/utils/formatters'; -import { AlertTriangle, CheckCircle, Download, Loader2, RefreshCw, Terminal } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle, + Download, + Loader2, + LogIn, + RefreshCw, + Terminal, +} from 'lucide-react'; // ============================================================================= // Border color by state // ============================================================================= -type BannerVariant = 'loading' | 'error' | 'success' | 'info'; +type BannerVariant = 'loading' | 'error' | 'success' | 'info' | 'warning'; const VARIANT_STYLES: Record = { loading: { border: 'var(--color-border)', bg: 'transparent' }, error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' }, success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' }, info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' }, + warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' }, }; // ============================================================================= @@ -148,6 +158,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => { isBusy, } = useCliInstaller(); + const [showLoginTerminal, setShowLoginTerminal] = useState(false); + useEffect(() => { if (isElectron) { void fetchCliStatus(); @@ -171,6 +183,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState !== 'idle') return 'info'; if (!cliStatus) return 'loading'; if (!cliStatus.installed) return 'error'; + if (cliStatus.installed && !cliStatus.authLoggedIn) return 'warning'; if (cliStatus.updateAvailable) return 'info'; return 'success'; }; @@ -368,6 +381,58 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ); } + // Installed but not logged in — yellow warning banner + if (cliStatus.installed && !cliStatus.authLoggedIn) { + return ( + <> +
+
+
+ +
+

+ Not logged in +

+

+ Claude CLI is installed but you are not authenticated. Login is required for team + provisioning and AI features. +

+
+
+ +
+
+ {showLoginTerminal && cliStatus.binaryPath && ( + { + setShowLoginTerminal(false); + void fetchCliStatus(); + }} + onExit={() => { + void fetchCliStatus(); + }} + /> + )} + + ); + } + // Installed — show version, path, update info return (
0 ? (
- {[...comments].reverse().map((comment) => ( -
-
- { - const rc = colorMap.get(comment.author); - return rc ? getTeamColorSet(rc).text : 'var(--color-text-secondary)'; - })(), - }} - > - {comment.author} - - - {(() => { - const date = new Date(comment.createdAt); - return isNaN(date.getTime()) - ? 'unknown time' - : formatDistanceToNow(date, { addSuffix: true }); - })()} - - - - + + Reply to comment + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = stripAgentBlocks(rawForDisplay); + const needsExpandCollapse = displayText.includes('\n'); + const expanded = expandedCommentIds.has(comment.id); + const collapsedHeight = 'max-h-[120px]'; + const showCollapsed = needsExpandCollapse && !expanded; + const showExpandedButton = needsExpandCollapse && expanded; + return ( +
+
- - Reply - - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = stripAgentBlocks(rawForDisplay); - const needsExpandCollapse = displayText.includes('\n'); - const expanded = expandedCommentIds.has(comment.id); - const collapsedHeight = 'max-h-[120px]'; - const showCollapsed = needsExpandCollapse && !expanded; - const showExpandedButton = needsExpandCollapse && expanded; - return ( -
-
- {reply ? ( - - ) : ( - - )} - {showCollapsed && ( - <> -
+ {reply ? ( + -
- -
- + ) : ( + + )} + {showCollapsed && ( + <> +
+
+ +
+ + )} +
+ {showExpandedButton && ( +
+ +
)}
- {showExpandedButton && ( -
- -
- )} -
- ); - })()} -
- ))} + ); + })()} +
+ ))}
) : null} diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx new file mode 100644 index 00000000..c1234061 --- /dev/null +++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx @@ -0,0 +1,126 @@ +import '@xterm/xterm/css/xterm.css'; + +import { useEffect, useRef } from 'react'; + +import { api } from '@renderer/api'; +import { FitAddon } from '@xterm/addon-fit'; +import { Terminal } from '@xterm/xterm'; + +import type { PtySpawnOptions } from '@shared/types/terminal'; + +interface EmbeddedTerminalProps { + /** Command to run (if not provided, opens default shell) */ + command?: string; + /** Arguments for the command */ + args?: string[]; + /** Working directory */ + cwd?: string; + /** Callback when PTY process exits */ + onExit?: (exitCode: number) => void; + /** CSS class for container */ + className?: string; +} + +export const EmbeddedTerminal = ({ + command, + args, + cwd, + onExit, + className, +}: EmbeddedTerminalProps): React.JSX.Element => { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let ptyId: string | null = null; + + const term = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: { + background: '#141416', + foreground: '#fafafa', + cursor: '#fafafa', + selectionBackground: 'rgba(255, 255, 255, 0.2)', + }, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(container); + + // Fit after opening so dimensions are correct + requestAnimationFrame(() => fitAddon.fit()); + + // User input → PTY (returns IDisposable — must dispose in cleanup) + const inputDisposable = term.onData((data) => { + if (ptyId) api.terminal.write(ptyId, data); + }); + + // PTY output → xterm + const unsubData = api.terminal.onData((_, id, data) => { + if (id === ptyId) term.write(data); + }); + + // PTY exit + const unsubExit = api.terminal.onExit((_, id, exitCode) => { + if (id === ptyId) { + ptyId = null; + onExit?.(exitCode); + } + }); + + // Spawn PTY + const spawnOptions: PtySpawnOptions = { + ...(command ? { command } : {}), + ...(args ? { args } : {}), + ...(cwd ? { cwd } : {}), + cols: term.cols, + rows: term.rows, + }; + + api.terminal + .spawn(spawnOptions) + .then((id) => { + ptyId = id; + // Send actual terminal size after spawn (fitAddon.fit() may have + // changed cols/rows via RAF after spawnOptions was constructed) + api.terminal.resize(id, term.cols, term.rows); + }) + .catch((err: unknown) => { + term.write( + `\r\n\x1b[31mFailed to start terminal: ${err instanceof Error ? err.message : String(err)}\x1b[0m\r\n` + ); + }); + + // ResizeObserver → fitAddon.fit() → pty.resize() + const observer = new ResizeObserver(() => { + fitAddon.fit(); + if (ptyId) { + api.terminal.resize(ptyId, term.cols, term.rows); + } + }); + observer.observe(container); + + return () => { + inputDisposable.dispose(); + unsubData(); + unsubExit(); + if (ptyId) api.terminal.kill(ptyId); + observer.disconnect(); + term.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run once on mount + }, []); + + return ( +
+ ); +}; diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx new file mode 100644 index 00000000..7c7d033d --- /dev/null +++ b/src/renderer/components/terminal/TerminalModal.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import ReactDOM from 'react-dom'; + +import { Terminal, X } from 'lucide-react'; + +import { EmbeddedTerminal } from './EmbeddedTerminal'; + +interface TerminalModalProps { + /** Modal title */ + title?: string; + /** Command to run */ + command?: string; + /** Arguments for the command */ + args?: string[]; + /** Working directory */ + cwd?: string; + /** Called when the modal should close */ + onClose: () => void; + /** Called when the PTY process exits */ + onExit?: (exitCode: number) => void; +} + +export function TerminalModal({ + title = 'Terminal', + command, + args, + cwd, + onClose, + onExit, +}: TerminalModalProps): React.JSX.Element { + const [exited, setExited] = useState(null); + + const handleExit = (exitCode: number): void => { + setExited(exitCode); + onExit?.(exitCode); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + + return ReactDOM.createPortal( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop +
+
+ {/* Header */} +
+
+ + {title} +
+ +
+ + {/* Terminal area */} +
+ {exited === null ? ( + + ) : ( +
+

+ Process exited with code{' '} + {exited} +

+ +
+ )} +
+
+
, + document.body + ); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 4088f2e6..831b5f53 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { TeamUpdateConfigRequest, UpdateKanbanPatch, } from './team'; +import type { TerminalAPI } from './terminal'; import type { WaterfallData } from './visualization'; import type { ConversationGroup, @@ -634,6 +635,9 @@ export interface ElectronAPI { // CLI Installer API cliInstaller: CliInstallerAPI; + + // Embedded Terminal API (xterm.js + node-pty) + terminal: TerminalAPI; } // ============================================================================= diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 63fe171d..2b98d443 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -39,6 +39,10 @@ export interface CliInstallationStatus { latestVersion: string | null; /** True when installed version < latest version */ updateAvailable: boolean; + /** Whether user is logged in (claude auth status) */ + authLoggedIn: boolean; + /** Auth method if logged in (e.g. "oauth_token", "api_key"), null otherwise */ + authMethod: string | null; } // ============================================================================= diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index bca36c2d..44cc0d30 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -32,3 +32,6 @@ export type * from './review'; // Re-export CLI Installer types export type * from './cliInstaller'; + +// Re-export Terminal types +export type * from './terminal'; diff --git a/src/shared/types/review.ts b/src/shared/types/review.ts index b3cfd314..55701847 100644 --- a/src/shared/types/review.ts +++ b/src/shared/types/review.ts @@ -9,6 +9,8 @@ export interface SnippetDiff { replaceAll: boolean; timestamp: string; isError: boolean; + /** Hash of ±3 surrounding context lines for reliable hunk↔snippet matching */ + contextHash?: string; } /** Агрегированные изменения по файлу */ diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 61d675ba..e14d38af 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -62,6 +62,8 @@ export interface TaskComment { createdAt: string; } +// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. +// Adding a field here without mapping it there will cause a compile error. export interface TeamTask { id: string; subject: string; diff --git a/src/shared/types/terminal.ts b/src/shared/types/terminal.ts new file mode 100644 index 00000000..fee724df --- /dev/null +++ b/src/shared/types/terminal.ts @@ -0,0 +1,49 @@ +/** + * Terminal types — shared between main, preload, and renderer processes. + * + * Used for embedded PTY terminal (xterm.js + node-pty). + */ + +// ============================================================================= +// PTY Spawn Options +// ============================================================================= + +/** + * Options for spawning a new PTY process. + */ +export interface PtySpawnOptions { + /** Command to run (default: user's shell) */ + command?: string; + /** Arguments for the command */ + args?: string[]; + /** Working directory */ + cwd?: string; + /** Environment variables (merged with process.env) */ + env?: Record; + /** Initial terminal columns */ + cols?: number; + /** Initial terminal rows */ + rows?: number; +} + +// ============================================================================= +// Preload API +// ============================================================================= + +/** + * Terminal API exposed via preload bridge. + */ +export interface TerminalAPI { + /** Spawn a new PTY process. Returns unique pty ID. */ + spawn: (options?: PtySpawnOptions) => Promise; + /** Write data to PTY stdin (fire-and-forget). */ + write: (ptyId: string, data: string) => void; + /** Resize PTY terminal (fire-and-forget). */ + resize: (ptyId: string, cols: number, rows: number) => void; + /** Kill PTY process (fire-and-forget). */ + kill: (ptyId: string) => void; + /** Subscribe to PTY data output. Returns cleanup function. */ + onData: (cb: (event: unknown, ptyId: string, data: string) => void) => () => void; + /** Subscribe to PTY exit events. Returns cleanup function. */ + onExit: (cb: (event: unknown, ptyId: string, exitCode: number) => void) => () => void; +}