diff --git a/package.json b/package.json index 81d5665ae..8f2538429 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,13 @@ "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "@tauri-apps/plugin-window-state": "~2.4.1", + "@tiptap/core": "3.26.0", + "@tiptap/extension-placeholder": "3.26.0", + "@tiptap/markdown": "3.26.0", + "@tiptap/pm": "3.26.0", + "@tiptap/react": "3.26.0", + "@tiptap/starter-kit": "3.26.0", + "@tiptap/suggestion": "3.26.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-ligatures": "^0.10.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adaa8a61a..5062fecde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,27 @@ importers: '@tauri-apps/plugin-window-state': specifier: ~2.4.1 version: 2.4.1 + '@tiptap/core': + specifier: 3.26.0 + version: 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-placeholder': + specifier: 3.26.0 + version: 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/markdown': + specifier: 3.26.0 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/pm': + specifier: 3.26.0 + version: 3.26.0 + '@tiptap/react': + specifier: 3.26.0 + version: 3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tiptap/starter-kit': + specifier: 3.26.0 + version: 3.26.0 + '@tiptap/suggestion': + specifier: 3.26.0 + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -2406,6 +2427,172 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.26.0': + resolution: {integrity: sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==} + peerDependencies: + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-blockquote@3.26.0': + resolution: {integrity: sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-bold@3.26.0': + resolution: {integrity: sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-bubble-menu@3.26.0': + resolution: {integrity: sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-bullet-list@3.26.0': + resolution: {integrity: sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-code-block@3.26.0': + resolution: {integrity: sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-code@3.26.0': + resolution: {integrity: sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-document@3.26.0': + resolution: {integrity: sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-dropcursor@3.26.0': + resolution: {integrity: sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-floating-menu@3.26.0': + resolution: {integrity: sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-gapcursor@3.26.0': + resolution: {integrity: sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-hard-break@3.26.0': + resolution: {integrity: sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-heading@3.26.0': + resolution: {integrity: sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-horizontal-rule@3.26.0': + resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-italic@3.26.0': + resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-link@3.26.0': + resolution: {integrity: sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-list-item@3.26.0': + resolution: {integrity: sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-list-keymap@3.26.0': + resolution: {integrity: sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-list@3.26.0': + resolution: {integrity: sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-ordered-list@3.26.0': + resolution: {integrity: sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==} + peerDependencies: + '@tiptap/extension-list': 3.26.0 + + '@tiptap/extension-paragraph@3.26.0': + resolution: {integrity: sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-placeholder@3.26.0': + resolution: {integrity: sha512-q9RsGWmL0xEwrMK5Wx53eMdZlkA3J1cqloIc69rEugjOCjoExEkfF4e2nC9YK49qGxJZBKKksw1Ij7oCWEyu+g==} + peerDependencies: + '@tiptap/extensions': 3.26.0 + + '@tiptap/extension-strike@3.26.0': + resolution: {integrity: sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-text@3.26.0': + resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extension-underline@3.26.0': + resolution: {integrity: sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==} + peerDependencies: + '@tiptap/core': 3.26.0 + + '@tiptap/extensions@3.26.0': + resolution: {integrity: sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/markdown@3.26.0': + resolution: {integrity: sha512-jg5xrwl1gTXUl5JA3+g8YYfhOzplM9CVecwKZeFtlYtPLyxLCmIDvqV/vULoGu57HwtY4819nNpMZwY6jBNtrw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/pm@3.26.0': + resolution: {integrity: sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==} + + '@tiptap/react@3.26.0': + resolution: {integrity: sha512-NLPAG6tk4/AsfOsUNsbGqdgIHuGsD4A/hlYriozuo+LCAAduuluhzsL/MEHZXtFT4GXUOlCdaEqNCOrMuz/zaw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.26.0': + resolution: {integrity: sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==} + + '@tiptap/suggestion@3.26.0': + resolution: {integrity: sha512-3jxBvjmfooQroR0eCw61kSgr+g90KFVD3dM5bANhpQbCpCYRydp/iXDQmpoPHjv8FpeU5JZgcnJ3R9vhjeIM2A==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -2576,6 +2763,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -3801,6 +3991,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -4599,6 +4793,9 @@ packages: linkify-it@3.0.3: resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5116,6 +5313,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -5295,6 +5495,45 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -5521,6 +5760,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -6193,6 +6435,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -8362,6 +8607,193 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tiptap/core@3.26.0(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-blockquote@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-bold@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-bubble-menu@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + optional: true + + '@tiptap/extension-bullet-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-code-block@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-code@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-document@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-dropcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-floating-menu@3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + optional: true + + '@tiptap/extension-gapcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-hard-break@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-heading@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-link@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-list-keymap@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/extension-ordered-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-paragraph@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-placeholder@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + + '@tiptap/extension-strike@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extension-underline@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/markdown@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + marked: 17.0.1 + + '@tiptap/pm@3.26.0': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + '@types/react': 19.2.13 + '@types/react-dom': 19.2.3(@types/react@19.2.13) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-floating-menu': 3.26.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.26.0': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-blockquote': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bold': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bullet-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-code': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-code-block': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-document': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-dropcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-gapcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-hard-break': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-heading': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-horizontal-rule': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-italic': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-link': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list-item': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-list-keymap': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-ordered-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-paragraph': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-strike': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-text': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-underline': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/suggestion@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -8565,6 +8997,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': @@ -10009,6 +10443,8 @@ snapshots: fast-diff@1.3.0: {} + fast-equals@5.4.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10850,6 +11286,8 @@ snapshots: dependencies: uc.micro: 1.0.6 + linkifyjs@4.3.3: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11629,6 +12067,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + orderedmap@2.1.1: {} + outvariant@1.4.3: {} overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4): @@ -11792,6 +12232,80 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.7: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.7 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -12147,6 +12661,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -12971,6 +13487,8 @@ snapshots: vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index d74c7f08b..e99fec405 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -976,6 +976,11 @@ pub struct DelegationInjection { /// of the three is on, and the companion's `--features` lists `ask` to expose /// the `ask_user_question` tool. pub ask: crate::acp::question::QuestionRuntimeConfig, + /// Hot-swappable "is get-session-info enabled?" flag. Read at injection time + /// alongside the other three so `codeg-mcp` is injected when ANY of the four + /// is on, and the companion's `--features` lists `sessions` to expose the + /// `get_session_info` tool. No teardown handle (the lookup is stateless). + pub sessions: crate::acp::session_info::SessionInfoRuntimeConfig, /// Question registry handle for the teardown cascade. The `run_connection` /// cleanup guard calls `cancel_questions_by_parent` through this so a pending /// `ask_user_question` is reclaimed synchronously on disconnect, mirroring @@ -1064,7 +1069,7 @@ fn is_executable_file(path: &Path) -> bool { /// delegate tool silently. Skipping leaves the agent fully functional minus /// `delegate_to_agent`, which is the right degradation when codeg-mcp didn't /// make it into the install. -/// The `--features` value for a companion launch given the three feature flags, +/// The `--features` value for a companion launch given the four feature flags, /// or `None` when none is enabled (the companion isn't injected at all). /// Pulled out as a pure function so the inject/skip decision is unit-testable /// without a real binary on disk or a live broker. @@ -1072,8 +1077,9 @@ fn companion_features_arg( delegation_enabled: bool, feedback_enabled: bool, ask_enabled: bool, + sessions_enabled: bool, ) -> Option { - if !delegation_enabled && !feedback_enabled && !ask_enabled { + if !delegation_enabled && !feedback_enabled && !ask_enabled && !sessions_enabled { return None; } let mut features: Vec<&str> = Vec::new(); @@ -1086,6 +1092,9 @@ fn companion_features_arg( if ask_enabled { features.push("ask"); } + if sessions_enabled { + features.push("sessions"); + } Some(features.join(",")) } @@ -1110,15 +1119,20 @@ async fn inject_codeg_mcp( let delegation_enabled = injection.broker.config_snapshot().await.enabled; let feedback_enabled = injection.feedback.is_enabled().await; let ask_enabled = injection.ask.is_enabled().await; + let sessions_enabled = injection.sessions.is_enabled().await; // `None` (no feature enabled) short-circuits the whole injection. - let features_arg = - companion_features_arg(delegation_enabled, feedback_enabled, ask_enabled)?; + let features_arg = companion_features_arg( + delegation_enabled, + feedback_enabled, + ask_enabled, + sessions_enabled, + )?; let Some(binary_path) = locate_codeg_mcp_binary() else { eprintln!( "[delegation][WARN] codeg-mcp companion binary not found (checked CODEG_MCP_BIN, \ exe sibling, and PATH); skipping delegate_to_agent / check_user_feedback / \ - ask_user_question tool injection for connection {parent_connection_id}. Reinstall \ - codeg or set CODEG_MCP_BIN to fix." + ask_user_question / get_session_info tool injection for connection \ + {parent_connection_id}. Reinstall codeg or set CODEG_MCP_BIN to fix." ); return None; }; @@ -1147,7 +1161,7 @@ async fn inject_codeg_mcp( // (any platform). "--parent-pid".to_string(), std::process::id().to_string(), - // Tool groups to expose this launch (delegation and/or feedback). + // Tool groups to expose this launch (delegation / feedback / ask / sessions). "--features".to_string(), features_arg, ]); @@ -4581,6 +4595,7 @@ mod tests { socket_path: std::path::PathBuf::from("/tmp/codeg-mcp.sock"), feedback: crate::acp::feedback::FeedbackRuntimeConfig::new(), ask: crate::acp::question::QuestionRuntimeConfig::new(), + sessions: crate::acp::session_info::SessionInfoRuntimeConfig::new(), questions: Arc::new(NoQuestions) as Arc, }; @@ -4617,27 +4632,32 @@ mod tests { #[test] fn companion_features_arg_inject_skip_decision() { // All off → no companion at all. - assert_eq!(companion_features_arg(false, false, false), None); + assert_eq!(companion_features_arg(false, false, false, false), None); // Delegation only. assert_eq!( - companion_features_arg(true, false, false), + companion_features_arg(true, false, false, false), Some("delegation".to_string()) ); // Feedback only — the decoupling: companion injected for feedback even // when delegation is off. assert_eq!( - companion_features_arg(false, true, false), + companion_features_arg(false, true, false, false), Some("feedback".to_string()) ); // Ask only — likewise injects the companion on its own. assert_eq!( - companion_features_arg(false, false, true), + companion_features_arg(false, false, true, false), Some("ask".to_string()) ); + // Sessions only — likewise injects the companion on its own. + assert_eq!( + companion_features_arg(false, false, false, true), + Some("sessions".to_string()) + ); // All on → comma-joined, in declaration order. assert_eq!( - companion_features_arg(true, true, true), - Some("delegation,feedback,ask".to_string()) + companion_features_arg(true, true, true, true), + Some("delegation,feedback,ask,sessions".to_string()) ); } } diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index df11e09b9..a91fd85ea 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -5,14 +5,16 @@ //! The companion speaks newline-delimited JSON-RPC 2.0 on stdio: //! one request → one response per line, with concurrent dispatch so //! `notifications/cancelled` can race an in-flight `tools/call`. It exposes up -//! to four tools — `delegate_to_agent` (async; returns a `task_id` ack), +//! to six tools — `delegate_to_agent` (async; returns a `task_id` ack), //! `get_delegation_status` (poll/long-poll for the result), `cancel_delegation`, -//! and `check_user_feedback` (pull the user's mid-turn steering notes) — whose -//! schemas are embedded at compile time from [`TOOL_SCHEMA_JSON`] and gated by -//! the `--features` groups (delegation / feedback). Only `delegate_to_agent` -//! registers a broker-side cancel handle; canceling a status / cancel / feedback -//! round-trip merely suppresses its response — and for `check_user_feedback` -//! also skips the delivery commit, so a cancelled note stays pending. +//! `check_user_feedback` (pull the user's mid-turn steering notes), +//! `ask_user_question` (block on a multiple-choice card), and `get_session_info` +//! (resolve a referenced session by id) — whose schemas are embedded at compile +//! time from [`TOOL_SCHEMA_JSON`] and gated by the `--features` groups (delegation +//! / feedback / ask / sessions). Only `delegate_to_agent` registers a broker-side +//! cancel handle; canceling a status / cancel / feedback / session round-trip +//! merely suppresses its response — and for `check_user_feedback` also skips the +//! delivery commit, so a cancelled note stays pending. //! //! Notifications (id = None) produce no response, matching MCP's expectation //! that `notifications/initialized` etc. are fire-and-forget. @@ -41,11 +43,13 @@ use tokio::sync::{oneshot, Mutex}; use crate::acp::delegation::transport::{ client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback, - client_feedback_round_trip, client_round_trip, client_status_round_trip, BrokerAskRequest, - BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerFeedbackRequest, - BrokerRequest, BrokerResponse, BrokerStatusRequest, + client_feedback_round_trip, client_round_trip, client_session_round_trip, + client_status_round_trip, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, + BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerRequest, BrokerResponse, + BrokerSessionRequest, BrokerStatusRequest, }; use crate::acp::question::parse_questions; +use crate::acp::session_info::MAX_SESSION_MESSAGES; /// Upper bound on one broker-side cancel round-trip. Bounds both /// `handle_cancel_notification` (so stdin dispatch can't stall behind a @@ -133,32 +137,36 @@ pub struct CompanionFeatures { pub delegation: bool, pub feedback: bool, pub ask: bool, + pub sessions: bool, } impl CompanionFeatures { - /// Parse the comma-joined `--features` value (e.g. `delegation,feedback,ask`). - /// Unknown tokens are ignored. An absent value (`None`) defaults to - /// delegation-only — backward compatible with a parent that predates - /// feature gating (companion + listener ship together, so post-upgrade the - /// parent always passes an explicit `--features`). + /// Parse the comma-joined `--features` value (e.g. + /// `delegation,feedback,ask,sessions`). Unknown tokens are ignored. An absent + /// value (`None`) defaults to delegation-only — backward compatible with a + /// parent that predates feature gating (companion + listener ship together, so + /// post-upgrade the parent always passes an explicit `--features`). pub fn parse(raw: Option<&str>) -> Self { let Some(s) = raw else { return Self { delegation: true, feedback: false, ask: false, + sessions: false, }; }; let mut f = Self { delegation: false, feedback: false, ask: false, + sessions: false, }; for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match tok { "delegation" => f.delegation = true, "feedback" => f.feedback = true, "ask" => f.ask = true, + "sessions" => f.sessions = true, _ => {} } } @@ -170,6 +178,7 @@ impl CompanionFeatures { match name { "check_user_feedback" => self.feedback, "ask_user_question" => self.ask, + "get_session_info" => self.sessions, "delegate_to_agent" | "get_delegation_status" | "cancel_delegation" => self.delegation, _ => false, } @@ -500,6 +509,36 @@ async fn build_tools_call_spawn( let round_trip = Box::pin(async move { client_ask_round_trip(&socket, &req).await }); register_and_spawn(inflight, id, None, round_trip, render_ask_result).await } + "get_session_info" => { + // `session_id` is the codeg conversation id the agent read out of a + // `codeg://session/` reference. Accept a JSON number or a numeric + // string (some hosts stringify integer args); reject anything else + // synchronously so the LLM can fix it. + let session_id = match parse_session_id(&arguments) { + Some(id) => id, + None => { + return LineAction::Respond(err( + id, + -32602, + "get_session_info requires an integer `session_id` \ + (the number in the codeg://session/ reference)", + )); + } + }; + // Default to a modest recent-message window; `0` means metadata-only. + // Robust against stringified / oversized values (see helper). + let max_messages = parse_max_messages(&arguments); + let req = BrokerSessionRequest { + token: ctx.token.clone(), + session_id, + max_messages: Some(max_messages), + }; + // No external_handle: a read-only lookup has nothing to cancel + // broker-side — canceling only suppresses the response. + let round_trip = + Box::pin(async move { client_session_round_trip(&socket, &req).await }); + register_and_spawn(inflight, id, None, round_trip, render_session_result).await + } other => LineAction::Respond(err(id, -32602, format!("unknown tool: {other}"))), } } @@ -929,6 +968,160 @@ pub fn render_ask_result(outcome: &Value) -> Value { }) } +/// Extract the `session_id` integer from the `get_session_info` arguments, +/// tolerating a JSON number (int or whole float) or a numeric string — some MCP +/// hosts stringify integer args. `None` for missing / non-integer / out-of-range, +/// which the dispatcher maps to a synchronous `-32602` the LLM can fix. +fn parse_session_id(arguments: &Value) -> Option { + let v = arguments.get("session_id")?; + if let Some(n) = v.as_i64() { + return i32::try_from(n).ok(); + } + if let Some(f) = v.as_f64() { + if f.fract() == 0.0 && f >= f64::from(i32::MIN) && f <= f64::from(i32::MAX) { + return Some(f as i32); + } + } + if let Some(s) = v.as_str() { + return s.trim().parse::().ok(); + } + None +} + +/// Parse the optional `max_messages` tuning arg robustly: a JSON number (integer +/// or whole non-negative float) or a numeric string — consistent with how +/// `session_id` tolerates stringified ints. Clamps in `u64` space BEFORE narrowing +/// to `u32`, so a huge value (e.g. `4294967296`) saturates to the cap instead of +/// wrapping to a small number. An absent OR unparseable value falls back to the +/// default window — it is an optional knob, not a hard error — while an explicit +/// `0` (or `"0"`) is preserved to mean metadata-only. +fn parse_max_messages(arguments: &Value) -> u32 { + const DEFAULT_MAX_MESSAGES: u32 = 20; + let Some(v) = arguments.get("max_messages") else { + return DEFAULT_MAX_MESSAGES; + }; + let raw: Option = if let Some(n) = v.as_u64() { + Some(n) + } else if let Some(f) = v.as_f64() { + // Reject negatives / fractions; `f as u64` saturates a huge float. + (f.fract() == 0.0 && f >= 0.0).then_some(f as u64) + } else if let Some(s) = v.as_str() { + s.trim().parse::().ok() + } else { + None + }; + match raw { + Some(n) => n.min(u64::from(MAX_SESSION_MESSAGES)) as u32, + None => DEFAULT_MAX_MESSAGES, + } +} + +/// Map the `get_session_info` round-trip outcome (a serialized +/// [`crate::acp::session_info::SessionInfo`]) into an MCP `tools/call` result. A +/// not-found result is surfaced as readable text with `isError: false` (the LLM +/// reads it and proceeds), never as a tool error. The full structured envelope +/// rides along in `structuredContent` for hosts that keep it. +pub fn render_session_result(outcome: &Value) -> Value { + let found = outcome + .get("found") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let text = if found { + render_session_summary_text(outcome) + } else { + outcome + .get("note") + .and_then(|v| v.as_str()) + .unwrap_or("No matching session was found.") + .to_string() + }; + json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "structuredContent": outcome.clone(), + }) +} + +/// Build the human-readable summary block for a found session: a metadata header +/// plus, when present, a "Recent messages" section. +fn render_session_summary_text(o: &Value) -> String { + let s = |k: &str| o.get(k).and_then(|v| v.as_str()); + let id = o.get("session_id").and_then(|v| v.as_i64()).unwrap_or(0); + let agent = s("agent_type").unwrap_or("unknown"); + let mut out = format!("Session #{id} ({agent})\n"); + if let Some(t) = s("title") { + out.push_str(&format!("Title: {t}\n")); + } + let mut meta: Vec = Vec::new(); + if let Some(v) = s("status") { + meta.push(format!("status: {v}")); + } + if let Some(v) = s("git_branch") { + meta.push(format!("branch: {v}")); + } + if let Some(v) = s("model") { + meta.push(format!("model: {v}")); + } + if !meta.is_empty() { + out.push_str(&meta.join(" | ")); + out.push('\n'); + } + if let Some(v) = s("workspace_path") { + out.push_str(&format!("Workspace: {v}\n")); + } + if let Some(n) = o.get("message_count").and_then(|v| v.as_u64()) { + out.push_str(&format!("Messages: {n}\n")); + } + if o.get("is_delegation_child") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let Some(p) = o.get("parent_id").and_then(|v| v.as_i64()) { + out.push_str(&format!("Delegation child of session #{p}\n")); + } + } + if let Some(tokens) = o + .get("stats") + .and_then(|st| st.get("total_tokens")) + .and_then(|v| v.as_u64()) + { + out.push_str(&format!("Total tokens: {tokens}\n")); + } + if let Some(note) = s("note") { + out.push_str(&format!("Note: {note}\n")); + } + if let Some(messages) = o.get("messages") { + let total = messages.get("total").and_then(|v| v.as_u64()).unwrap_or(0); + let included = messages + .get("included") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let truncated = messages + .get("truncated") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let suffix = if truncated { ", older turns omitted" } else { "" }; + out.push_str(&format!("\nRecent messages ({included}/{total}{suffix}):\n")); + if let Some(items) = messages.get("items").and_then(|v| v.as_array()) { + for item in items { + let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("?"); + let body = item.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let tools: Vec<&str> = item + .get("tools") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str()).collect()) + .unwrap_or_default(); + out.push_str(&format!("- [{role}] {body}")); + if !tools.is_empty() { + out.push_str(&format!(" (tools: {})", tools.join(", "))); + } + out.push('\n'); + } + } + } + out +} + pub fn render_task_report(report: &Value) -> Value { let status = report.get("status").and_then(|v| v.as_str()).unwrap_or(""); let is_error = status == "failed"; @@ -970,6 +1163,7 @@ mod tests { delegation: true, feedback: false, ask: false, + sessions: false, }) } @@ -1436,16 +1630,25 @@ mod tests { delegation: false, feedback: true, ask: false, + sessions: false, }; const BOTH: CompanionFeatures = CompanionFeatures { delegation: true, feedback: true, ask: false, + sessions: false, }; const ASK_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, feedback: false, ask: true, + sessions: false, + }; + const SESSIONS_ONLY: CompanionFeatures = CompanionFeatures { + delegation: false, + feedback: false, + ask: false, + sessions: true, }; fn list_tool_names(action: LineAction) -> Vec { @@ -1464,16 +1667,19 @@ mod tests { let def = CompanionFeatures::parse(None); assert!(def.delegation && !def.feedback); assert!(!def.ask); + assert!(!def.sessions); // Explicit list, whitespace + unknown tokens tolerated. - let all = CompanionFeatures::parse(Some(" delegation , feedback , ask ,bogus")); - assert!(all.delegation && all.feedback && all.ask); + let all = CompanionFeatures::parse(Some(" delegation , feedback , ask , sessions ,bogus")); + assert!(all.delegation && all.feedback && all.ask && all.sessions); let fb = CompanionFeatures::parse(Some("feedback")); assert!(!fb.delegation && fb.feedback && !fb.ask); let ask = CompanionFeatures::parse(Some("ask")); assert!(!ask.delegation && !ask.feedback && ask.ask); + let sessions = CompanionFeatures::parse(Some("sessions")); + assert!(!sessions.delegation && !sessions.feedback && !sessions.ask && sessions.sessions); // Empty string → nothing enabled. let none = CompanionFeatures::parse(Some("")); - assert!(!none.delegation && !none.feedback && !none.ask); + assert!(!none.delegation && !none.feedback && !none.ask && !none.sessions); } #[tokio::test] @@ -1639,6 +1845,171 @@ mod tests { assert!(text.contains("dismissed")); } + // -- get_session_info feature gating + parsing + rendering ------------- + + #[tokio::test] + async fn tools_list_includes_session_only_when_enabled() { + // Default ctx is delegation-only: get_session_info must NOT appear. + let names = list_tool_names( + dispatch_for_test(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await, + ); + assert!(!names.contains(&"get_session_info".to_string())); + // sessions feature on → exactly that one tool surfaces. + let names = list_tool_names( + dispatch_with_features( + SESSIONS_ONLY, + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, + ) + .await, + ); + assert_eq!(names, vec!["get_session_info".to_string()]); + } + + #[tokio::test] + async fn get_session_info_spawns_when_valid_and_enabled() { + let line = json!({ + "jsonrpc": "2.0", "id": 30, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": 214 } } + }) + .to_string(); + assert!(matches!( + dispatch_with_features(SESSIONS_ONLY, &line).await, + LineAction::Spawn(_) + )); + } + + #[tokio::test] + async fn get_session_info_accepts_numeric_string_id() { + // Some hosts stringify integer args — still resolves to a Spawn. + let line = json!({ + "jsonrpc": "2.0", "id": 31, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": "214" } } + }) + .to_string(); + assert!(matches!( + dispatch_with_features(SESSIONS_ONLY, &line).await, + LineAction::Spawn(_) + )); + } + + #[tokio::test] + async fn get_session_info_missing_or_bad_id_rejected_synchronously() { + for args in [json!({}), json!({ "session_id": "abc" }), json!({ "session_id": true })] { + let line = json!({ + "jsonrpc": "2.0", "id": 32, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": args } + }) + .to_string(); + let resp = unwrap_respond(dispatch_with_features(SESSIONS_ONLY, &line).await); + let e = resp.error.expect("bad session_id must be rejected"); + assert_eq!(e.code, -32602); + assert!(e.message.contains("session_id")); + } + } + + #[tokio::test] + async fn get_session_info_rejected_as_unknown_when_feature_off() { + // Default ctx is delegation-only — calling the tool by name is rejected + // uniformly as an unknown tool (no leak that the feature exists but is off). + let line = json!({ + "jsonrpc": "2.0", "id": 33, "method": "tools/call", + "params": { "name": "get_session_info", "arguments": { "session_id": 1 } } + }) + .to_string(); + let resp = unwrap_respond(dispatch_for_test(&line).await); + let e = resp.error.unwrap(); + assert_eq!(e.code, -32602); + assert!(e.message.contains("unknown tool")); + } + + #[test] + fn parse_session_id_tolerates_number_string_and_whole_float() { + assert_eq!(parse_session_id(&json!({ "session_id": 7 })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": " 7 " })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": 7.0 })), Some(7)); + assert_eq!(parse_session_id(&json!({ "session_id": "abc" })), None); + assert_eq!(parse_session_id(&json!({ "session_id": 7.5 })), None); + assert_eq!(parse_session_id(&json!({})), None); + } + + #[test] + fn parse_max_messages_is_robust() { + // Omitted → default. + assert_eq!(parse_max_messages(&json!({})), 20); + // Explicit 0 (number AND string) is preserved → metadata-only. + assert_eq!(parse_max_messages(&json!({ "max_messages": 0 })), 0); + assert_eq!(parse_max_messages(&json!({ "max_messages": "0" })), 0); + // Plain value within range. + assert_eq!(parse_max_messages(&json!({ "max_messages": 5 })), 5); + assert_eq!(parse_max_messages(&json!({ "max_messages": "5" })), 5); + // Whole float ok; over the cap clamps to MAX_SESSION_MESSAGES. + assert_eq!(parse_max_messages(&json!({ "max_messages": 50.0 })), 50); + assert_eq!(parse_max_messages(&json!({ "max_messages": 999 })), 200); + // A huge value must SATURATE to the cap, not wrap to a small number. + assert_eq!( + parse_max_messages(&json!({ "max_messages": 4_294_967_296_u64 })), + 200 + ); + assert_eq!( + parse_max_messages(&json!({ "max_messages": 1e30 })), + 200 + ); + // Invalid / negative / fractional → default (optional knob, not an error). + assert_eq!(parse_max_messages(&json!({ "max_messages": "abc" })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": -5 })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": 5.5 })), 20); + assert_eq!(parse_max_messages(&json!({ "max_messages": true })), 20); + } + + #[test] + fn render_session_result_not_found_is_soft_with_note_text() { + let outcome = json!({ + "found": false, "session_id": 9, + "note": "No session matches id 9. It may have been deleted, or never imported into codeg." + }); + let rendered = render_session_result(&outcome); + assert_eq!(rendered["isError"], false); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("No session matches id 9")); + assert_eq!(rendered["structuredContent"]["found"], false); + } + + #[test] + fn render_session_result_found_renders_metadata_and_messages() { + let outcome = json!({ + "found": true, + "session_id": 214, + "agent_type": "claude_code", + "title": "Fix auth flow", + "status": "completed", + "git_branch": "main", + "model": "claude-opus-4-8", + "workspace_path": "/home/me/proj", + "message_count": 12, + "is_delegation_child": false, + "stats": { "total_tokens": 4242 }, + "messages": { + "total": 12, "included": 2, "truncated": true, + "items": [ + { "role": "user", "text": "fix the login", "tools": [] }, + { "role": "assistant", "text": "done", "tools": ["Read", "Edit"] } + ] + } + }); + let rendered = render_session_result(&outcome); + assert_eq!(rendered["isError"], false); + let text = rendered["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("Session #214 (claude_code)")); + assert!(text.contains("Fix auth flow")); + assert!(text.contains("status: completed")); + assert!(text.contains("Workspace: /home/me/proj")); + assert!(text.contains("Total tokens: 4242")); + assert!(text.contains("Recent messages (2/12, older turns omitted)")); + assert!(text.contains("- [assistant] done (tools: Read, Edit)")); + // Full structured envelope preserved for hosts that keep it. + assert_eq!(rendered["structuredContent"]["session_id"], 214); + } + #[test] fn render_feedback_empty_is_not_error_and_says_no_feedback() { let rendered = render_feedback_result(&json!({ "count": 0, "feedback": [] })); diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 650eff410..10afe0df0 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -19,11 +19,12 @@ use crate::acp::delegation::broker::{DelegationBroker, StatusWait}; use crate::acp::delegation::transport::{ read_frame, write_frame, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest, BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerMessage, BrokerRequest, - BrokerResponse, BrokerStatusRequest, + BrokerResponse, BrokerSessionRequest, BrokerStatusRequest, }; use crate::acp::delegation::types::{DelegationRequest, DelegationTaskReport, TaskStatus}; use crate::acp::feedback::{PendingFeedback, SessionFeedbackAccess}; use crate::acp::question::{QuestionOutcome, SessionQuestionAccess}; +use crate::acp::session_info::{SessionInfo, SessionInfoAccess}; use crate::models::AgentType; use serde_json::Value; @@ -90,15 +91,21 @@ pub struct DelegationListener { /// Registers / cancels the blocking `ask_user_question` tool's pending /// questions. Same `tokens` registry and parent-connection scoping. pub questions: Arc, + /// Resolves a referenced session for the `get_session_info` tool. Unlike the + /// other arms this is NOT parent-scoped — it looks any non-deleted session up + /// by its codeg conversation id (still token-gated against an invalid caller). + pub session_info: Arc, } impl DelegationListener { + #[allow(clippy::too_many_arguments)] pub fn new( broker: Arc, tokens: Arc, parent_lookup: Arc, feedback: Arc, questions: Arc, + session_info: Arc, ) -> Arc { Arc::new(Self { broker, @@ -106,6 +113,7 @@ impl DelegationListener { parent_lookup, feedback, questions, + session_info, }) } @@ -296,6 +304,13 @@ impl DelegationListener { write_frame(conn, &resp).await?; return Ok(()); } + BrokerMessage::SessionInfo(req) => { + // Read-only resolution (DB + a bounded transcript parse). No + // peer-close race needed: unlike Status/Ask this never blocks on + // a long-poll or a human — the bounded parse always completes — + // and there is nothing to tear down on cancel. + session_response(self.process_session_info(req).await)? + } BrokerMessage::Cancel(cancel) => { self.process_cancel(cancel).await; // Empty ack — the companion only uses this to detect the @@ -405,6 +420,30 @@ impl DelegationListener { .await; } + /// Validate the token and resolve the `get_session_info` target. An invalid + /// token yields a `found:false` outcome (the LLM can't usefully distinguish it + /// from a deleted session, and we don't leak which). + /// + /// SCOPE (deliberate, user-confirmed): the lookup is by codeg conversation id + /// and is intentionally NOT scoped to the caller's parent connection or to the + /// session ids actually referenced in the prompt — any non-deleted session + /// resolves. This is sound in codeg's single-tenant trust model: there is no + /// per-user isolation anywhere (desktop is one local user; server mode shares + /// one `CODEG_TOKEN` + one data dir across an operator's devices), the user can + /// already open every session in the UI, and the agent already has full + /// filesystem access to every agent's raw session files via its own tools — so + /// reading session metadata by id is strictly less capability than the agent + /// already holds, not an escalation. The token gate above still prevents an + /// unrelated process from reaching the broker at all. + async fn process_session_info(&self, req: BrokerSessionRequest) -> SessionInfo { + if self.tokens.lookup(&req.token).await.is_none() { + return SessionInfo::not_found(req.session_id); + } + self.session_info + .resolve(req.session_id, req.max_messages.unwrap_or(0)) + .await + } + async fn process(&self, req: BrokerRequest) -> DelegationTaskReport { // 1. Token + parent_connection_id consistency check. Treat both as // "canceled" since the LLM can't usefully react to either — @@ -524,6 +563,17 @@ fn ask_response(outcome: &QuestionOutcome) -> std::io::Result { }) } +/// Serialize a resolved [`SessionInfo`] into a [`BrokerResponse`] for the +/// `SessionInfo` arm — the companion renders it into the `get_session_info` +/// tool result. +fn session_response(info: SessionInfo) -> std::io::Result { + Ok(BrokerResponse { + outcome: serde_json::to_value(&info).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("encode: {e}")) + })?, + }) +} + /// The `declined` outcome — used when the token is invalid, the connection is /// gone, or the answer one-shot was dropped without a response. The LLM reads it /// as "the user didn't answer; proceed with your own judgment". @@ -715,6 +765,31 @@ mod tests { } } + /// In-memory session-info stub. Records every `(session_id, max_messages)` it + /// was asked to resolve and returns a seeded outcome — `found` sessions echo + /// their id, unknown ids return `not_found`. Default knows about no sessions. + #[derive(Default)] + struct StubSessionInfo { + known: std::collections::HashSet, + calls: tokio::sync::Mutex>, + } + #[async_trait] + impl SessionInfoAccess for StubSessionInfo { + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo { + self.calls.lock().await.push((session_id, max_messages)); + if self.known.contains(&session_id) { + SessionInfo { + found: true, + session_id, + title: Some(format!("session {session_id}")), + ..Default::default() + } + } else { + SessionInfo::not_found(session_id) + } + } + } + use tokio::sync::oneshot; async fn make_broker(mock: Arc) -> Arc { @@ -746,6 +821,7 @@ mod tests { Arc::new(StaticParentLookup(parent_conversation)), Arc::new(StubFeedback::default()), Arc::new(StubQuestion::default()), + Arc::new(StubSessionInfo::default()), ) } @@ -765,6 +841,7 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), feedback, Arc::new(StubQuestion::default()), + Arc::new(StubSessionInfo::default()), ) } @@ -785,6 +862,27 @@ mod tests { Arc::new(StaticParentLookup(Some(1))), Arc::new(StubFeedback::default()), questions, + Arc::new(StubSessionInfo::default()), + ) + } + + /// Build a listener whose session-info access is the given stub, so + /// `get_session_info` tests can seed known sessions and assert the round-trip. + fn make_session_listener( + tokens: Arc, + session_info: Arc, + ) -> Arc { + let broker = Arc::new(DelegationBroker::new( + Arc::new(MockSpawner::new()) as Arc, + Arc::new(AlwaysRootLookup) as Arc, + )); + DelegationListener::new( + broker, + tokens, + Arc::new(StaticParentLookup(Some(1))), + Arc::new(StubFeedback::default()), + Arc::new(StubQuestion::default()), + session_info, ) } @@ -1502,6 +1600,117 @@ mod tests { assert!(feedback.committed.lock().await.is_empty()); } + /// A valid `get_session_info` resolves the session by id and returns its + /// metadata; the resolver is called with the requested id + max_messages. + #[tokio::test] + async fn session_info_valid_token_resolves_by_id() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([42]), + ..Default::default() + }); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_session_listener(tokens, session_info.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: 42, + max_messages: Some(15), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["found"], true); + assert_eq!(resp.outcome["session_id"], 42); + assert_eq!(resp.outcome["title"], "session 42"); + // The resolver saw the id + the requested message budget. + assert_eq!(session_info.calls.lock().await.as_slice(), &[(42, 15)]); + } + + /// Accepted-policy coverage (deliberate single-tenant scope): a single valid + /// token resolves ANY non-deleted session id — not only ids "referenced" in the + /// prompt. Three unrelated ids all resolve through one token. + #[tokio::test] + async fn session_info_resolves_any_session_id_not_just_referenced() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([7, 42, 1000]), + ..Default::default() + }); + let tokens = Arc::new(TokenRegistry::default()); + tokens + .register( + "tok".into(), + TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: PathBuf::from("/tmp"), + }, + ) + .await; + let listener = make_session_listener(tokens, session_info.clone()); + + for id in [7, 42, 1000] { + let (mut client, mut server) = duplex(8 * 1024); + let l = listener.clone(); + let server_task = tokio::spawn(async move { + l.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: id, + max_messages: Some(0), + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + assert_eq!(resp.outcome["found"], true, "id {id} should resolve"); + assert_eq!(resp.outcome["session_id"], id); + } + } + + /// An invalid token yields a `found:false` outcome WITHOUT touching the + /// resolver (no leak of whether the session exists). + #[tokio::test] + async fn session_info_invalid_token_is_not_found_without_resolving() { + let session_info = Arc::new(StubSessionInfo { + known: std::collections::HashSet::from([42]), + ..Default::default() + }); + // No token registered. + let tokens = Arc::new(TokenRegistry::default()); + let listener = make_session_listener(tokens, session_info.clone()); + + let (mut client, mut server) = duplex(8 * 1024); + let server_task = tokio::spawn(async move { + listener.serve_one(&mut server).await.unwrap(); + }); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "bogus".into(), + session_id: 42, + max_messages: None, + }); + write_frame(&mut client, &msg).await.unwrap(); + let resp: BrokerResponse = read_frame(&mut client).await.unwrap(); + server_task.await.unwrap(); + + assert_eq!(resp.outcome["found"], false); + assert_eq!(resp.outcome["session_id"], 42); + // The resolver was never consulted for an unauthenticated caller. + assert!(session_info.calls.lock().await.is_empty()); + } + /// `CommitFeedback` marks the named ids delivered, scoped (via the token) to /// the parent connection — the companion sends this only after it delivers. #[tokio::test] diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 85b2d66e8..6322206e3 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -127,5 +127,24 @@ } } } + }, + { + "name": "get_session_info", + "description": "Look up a session/conversation the user referenced in their message and return what it is: its title, which agent ran it, its status, workspace folder, git branch, model, message count, and token-usage stats — and, on request, a compact view of its most recent messages. Call this whenever the user's message contains a `codeg://session/` reference (a mentioned session) and you need to know what that session is about or what happened in it (e.g. to continue its work or summarize it). Pass the NUMBER from the reference as `session_id` (it is codeg's internal conversation id, NOT the agent's own session id). The result is read-only and never modifies the session. A session that no longer exists returns a `found: false` result you can act on — it is not an error.", + "inputSchema": { + "type": "object", + "required": ["session_id"], + "properties": { + "session_id": { + "type": "integer", + "description": "The number in the `codeg://session/` reference from the user's message (codeg's internal conversation id). For example, given `[Fix auth](codeg://session/214)`, pass 214." + }, + "max_messages": { + "type": "integer", + "minimum": 0, + "description": "How many of the session's most recent turns to include as compact text. Defaults to 20; pass 0 for metadata only (faster, no transcript read). Capped at 200." + } + } + } } ] diff --git a/src-tauri/src/acp/delegation/transport.rs b/src-tauri/src/acp/delegation/transport.rs index 7cdddfd62..41b81728a 100644 --- a/src-tauri/src/acp/delegation/transport.rs +++ b/src-tauri/src/acp/delegation/transport.rs @@ -165,6 +165,24 @@ pub struct BrokerAskRequest { pub questions: Vec, } +/// Resolve a session the user referenced (`codeg://session/`) into its +/// metadata + stats, optionally with its recent messages. Backs the +/// `get_session_info` MCP tool. Authenticated by the same per-launch `token`; the +/// lookup is by codeg's internal conversation id (the number in the reference), +/// so — unlike the delegation arms — it is NOT scoped to the parent connection +/// (any non-deleted session the user references can be read). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrokerSessionRequest { + pub token: String, + /// codeg's internal conversation PK (the number in `codeg://session/`). + pub session_id: i32, + /// How many of the most recent turns to include as compacted text. `None` / + /// `0` → metadata only (no transcript parse); a positive value is clamped to + /// [`crate::acp::session_info::MAX_SESSION_MESSAGES`] by the resolver. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_messages: Option, +} + /// Tagged top-level message dispatched by the listener. Adding new variants /// is the wire-stable way to grow the broker protocol without touching the /// frame layer. @@ -178,6 +196,7 @@ pub enum BrokerMessage { Feedback(BrokerFeedbackRequest), CommitFeedback(BrokerCommitFeedbackRequest), Ask(BrokerAskRequest), + SessionInfo(BrokerSessionRequest), } /// The wrapped outcome the main process returns over the same socket. @@ -317,6 +336,16 @@ pub async fn client_ask_round_trip( message_round_trip(socket_path, &BrokerMessage::Ask(req.clone())).await } +/// Dispatch a `get_session_info` request and read back the serialized +/// [`crate::acp::session_info::SessionInfo`] envelope (metadata + stats, and the +/// recent messages when `max_messages > 0`). +pub async fn client_session_round_trip( + socket_path: &str, + req: &BrokerSessionRequest, +) -> io::Result { + message_round_trip(socket_path, &BrokerMessage::SessionInfo(req.clone())).await +} + /// Total budget for `open()` retries on Windows named pipes. Has to be /// short enough that it nests comfortably inside the companion's /// `BROKER_CANCEL_BUDGET` (500 ms) — leaving ≥ 300 ms for the actual @@ -421,6 +450,26 @@ mod tests { } } + #[tokio::test] + async fn session_message_round_trip_in_memory() { + let (mut a, mut b) = duplex(8 * 1024); + let msg = BrokerMessage::SessionInfo(BrokerSessionRequest { + token: "tok".into(), + session_id: 42, + max_messages: Some(20), + }); + write_frame(&mut a, &msg).await.unwrap(); + let got: BrokerMessage = read_frame(&mut b).await.unwrap(); + match got { + BrokerMessage::SessionInfo(req) => { + assert_eq!(req.token, "tok"); + assert_eq!(req.session_id, 42); + assert_eq!(req.max_messages, Some(20)); + } + other => panic!("expected SessionInfo variant, got {other:?}"), + } + } + #[tokio::test] async fn cancel_message_round_trip_in_memory() { let (mut a, mut b) = duplex(8 * 1024); diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index fb032c81d..bc5d0925b 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -14,6 +14,7 @@ pub mod opencode_plugins; pub mod preflight; pub mod question; pub mod registry; +pub mod session_info; pub mod session_state; pub mod terminal_runtime; pub mod types; diff --git a/src-tauri/src/acp/session_info.rs b/src-tauri/src/acp/session_info.rs new file mode 100644 index 000000000..346f64d75 --- /dev/null +++ b/src-tauri/src/acp/session_info.rs @@ -0,0 +1,209 @@ +//! Session-info lookup domain types — backing the `get_session_info` MCP tool. +//! +//! When the user references a session in the composer it serializes into the +//! agent's prompt as `[title](codeg://session/)`. The agent +//! reads the numeric id out of that link and calls `get_session_info`, which +//! resolves it to the session's metadata + token-usage stats and, on demand, a +//! bounded compacted view of its recent messages. +//! +//! This module holds the layer-shared pieces (mirroring [`crate::acp::question`] +//! / [`crate::acp::feedback`]): +//! * [`SessionInfo`] / [`SessionMessages`] / [`SessionMessageItem`] — the +//! self-describing outcome delivered over the broker socket to the tool (so +//! the companion renders it without re-querying). +//! * [`SessionInfoAccess`] — the listener-facing trait the production +//! `DbSessionInfoLookup` (in `crate::commands::session_info`) implements; kept +//! here so the listener can be unit-tested with an in-memory stub. +//! * [`SessionInfoRuntimeConfig`] — the hot-swappable "is the feature on?" flag, +//! read at MCP injection time (mirrors [`crate::acp::question`]). + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +use crate::models::conversation::SessionStats; + +/// Hard cap on how many recent turns a single `get_session_info` call may +/// request. The companion clamps the agent-supplied `max_messages` to this, and +/// the resolver treats it as the authoritative ceiling so a pathological value +/// can't blow up the UDS frame or the LLM context. +pub const MAX_SESSION_MESSAGES: u32 = 200; + +/// One compacted turn from the referenced session's transcript. NEVER carries a +/// raw `MessageTurn` / image bytes — only role + truncated text + tool names — so +/// the UDS frame and the agent's context window stay bounded. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessageItem { + /// `"user"` | `"assistant"` | `"system"`. + pub role: String, + /// Concatenated text/thinking content of the turn, truncated to a per-turn + /// character budget. + pub text: String, + /// Tool names invoked in this turn (deduped, first-seen order); empty when + /// the turn ran no tools. + pub tools: Vec, +} + +/// The recent-messages slice of a [`SessionInfo`], present only when the caller +/// asked for messages (`max_messages > 0`) and the transcript parsed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessages { + /// Total number of turns in the session. + pub total: u32, + /// How many turns are included in `items` (the most recent ones). + pub included: u32, + /// True when older turns were dropped to fit `included` / the char budget. + pub truncated: bool, + pub items: Vec, +} + +/// The resolved session description handed back to the `get_session_info` tool. +/// `found == false` is a soft "no such session" result the LLM reads (not an +/// error), produced via [`SessionInfo::not_found`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionInfo { + pub found: bool, + /// The id that was queried (codeg's internal conversation PK). Echoed so the + /// companion can render a precise not-found message. + pub session_id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + /// Absolute path of the session's workspace folder. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option>, + /// The parent conversation id when this session is a delegation child. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + /// `true` when this session was spawned by a `delegate_to_agent` call. + pub is_delegation_child: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub messages: Option, + /// Human-readable note for a not-found result, or a partial one (e.g. the + /// transcript parse timed out so `messages` is absent). `None` on a clean + /// full result. + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +impl SessionInfo { + /// A "no session matches this id" outcome — the id was well-formed but no + /// non-deleted conversation row exists for it. + pub fn not_found(session_id: i32) -> Self { + Self { + found: false, + session_id, + note: Some(format!( + "No session matches id {session_id}. It may have been deleted, \ + or never imported into codeg." + )), + ..Default::default() + } + } +} + +/// Listener-facing access to resolve a session by its codeg conversation id. The +/// production impl (`crate::commands::session_info::DbSessionInfoLookup`) reads +/// the DB + parses the on-disk transcript; tests use an in-memory stub. Mirrors +/// [`crate::acp::feedback::SessionFeedbackAccess`] and +/// [`crate::acp::question::SessionQuestionAccess`]. +#[async_trait] +pub trait SessionInfoAccess: Send + Sync { + /// Resolve `session_id` (codeg's internal conversation PK) into a + /// [`SessionInfo`]. When `max_messages > 0`, include up to that many of the + /// most recent compacted turns (capped at [`MAX_SESSION_MESSAGES`]). A + /// missing / deleted id yields [`SessionInfo::not_found`]. + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo; +} + +/// The hot-swappable feature config read at MCP injection time. Kept tiny and +/// separate from the other feature configs so the `sessions` tool group toggles +/// independently — `codeg-mcp` is injected when ANY feature is enabled, and each +/// tool is listed only when its own feature is on. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionInfoConfig { + pub enabled: bool, +} + +/// Shared, hot-swappable handle to [`SessionInfoConfig`]. Cloned into +/// `DelegationInjection` (read at injection) and `AppState` (updated on save). +/// Byte-for-byte mirror of [`crate::acp::question::QuestionRuntimeConfig`]. +#[derive(Clone, Default)] +pub struct SessionInfoRuntimeConfig { + inner: Arc>, +} + +impl SessionInfoRuntimeConfig { + pub fn new() -> Self { + Self::default() + } + + pub async fn snapshot(&self) -> SessionInfoConfig { + self.inner.read().await.clone() + } + + pub async fn set(&self, cfg: SessionInfoConfig) { + *self.inner.write().await = cfg; + } + + /// Convenience read used at MCP injection time. + pub async fn is_enabled(&self) -> bool { + self.inner.read().await.enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn not_found_is_soft_and_carries_id() { + let info = SessionInfo::not_found(42); + assert!(!info.found); + assert_eq!(info.session_id, 42); + assert!(info.messages.is_none()); + assert!(info.note.as_deref().unwrap().contains("42")); + } + + #[test] + fn not_found_serializes_without_absent_option_fields() { + // The skip_serializing_if Options keep the not-found envelope compact. + let v = serde_json::to_value(SessionInfo::not_found(7)).unwrap(); + assert_eq!(v["found"], false); + assert_eq!(v["session_id"], 7); + assert!(v.get("title").is_none()); + assert!(v.get("messages").is_none()); + assert!(v.get("note").is_some()); + } + + #[tokio::test] + async fn runtime_config_round_trips() { + let cfg = SessionInfoRuntimeConfig::new(); + assert!(!cfg.is_enabled().await); + cfg.set(SessionInfoConfig { enabled: true }).await; + assert!(cfg.is_enabled().await); + assert_eq!(cfg.snapshot().await, SessionInfoConfig { enabled: true }); + } +} diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 67eabca26..7d43d873e 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -54,6 +54,11 @@ pub struct AppState { /// the question settings command on save. Populated at startup by /// `apply_persisted_question_config`. pub question_config: crate::acp::question::QuestionRuntimeConfig, + /// Hot-swappable get-session-info (`get_session_info`) enable flag. Shared + /// with the `DelegationInjection` so MCP injection reads it, and updated by + /// the session-info settings command on save. Populated at startup by + /// `apply_persisted_session_info_config`. + pub session_info_config: crate::acp::session_info::SessionInfoRuntimeConfig, /// Serializes mutually-exclusive system operations — in-place /// self-update, restart, rollback — so a second click can't race a /// download/swap already in flight. Handlers `try_lock` and reject when @@ -103,6 +108,7 @@ pub fn build_delegation_stack( PathBuf, crate::acp::feedback::FeedbackRuntimeConfig, crate::acp::question::QuestionRuntimeConfig, + crate::acp::session_info::SessionInfoRuntimeConfig, ) { use crate::acp::connection::DelegationInjection; use crate::acp::delegation::broker::{ @@ -148,6 +154,7 @@ pub fn build_delegation_stack( let socket_path = default_socket_path(&std::env::temp_dir()); let feedback = crate::acp::feedback::FeedbackRuntimeConfig::new(); let ask = crate::acp::question::QuestionRuntimeConfig::new(); + let sessions = crate::acp::session_info::SessionInfoRuntimeConfig::new(); // Install the injection on the manager so spawn_agent picks it up // without an extra parameter at every call site. @@ -157,6 +164,7 @@ pub fn build_delegation_stack( socket_path: socket_path.clone(), feedback: feedback.clone(), ask: ask.clone(), + sessions: sessions.clone(), // Same backing manager as the listener's question lookup; used only by // the run_connection teardown guard to reclaim a parked ask. questions: Arc::new(crate::acp::manager::ConnectionManagerQuestionLookup { @@ -164,7 +172,7 @@ pub fn build_delegation_stack( }) as Arc, }); - (broker, tokens, socket_path, feedback, ask) + (broker, tokens, socket_path, feedback, ask, sessions) } impl AppState { @@ -191,6 +199,7 @@ impl AppState { delegation_socket_path, feedback_config, question_config, + session_info_config, ) = build_delegation_stack(&connection_manager, db.conn.clone(), data_dir.clone()); Self { @@ -214,6 +223,7 @@ impl AppState { delegation_socket_path, feedback_config, question_config, + session_info_config, system_op_lock: default_system_op_lock(), update_state: default_update_state(), } diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs index 86a5562b2..3543f3158 100644 --- a/src-tauri/src/bin/codeg_mcp.rs +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -1,7 +1,9 @@ //! `codeg-mcp` — the per-launch stdio MCP companion that an agent CLI runs //! to surface codeg's tools to its LLM: the multi-agent delegation tools -//! (`delegate_to_agent` etc.) and/or `check_user_feedback` (pull the user's -//! mid-turn steering notes), gated by the `--features` groups. +//! (`delegate_to_agent` etc.), `check_user_feedback` (pull the user's mid-turn +//! steering notes), `ask_user_question` (block on a multiple-choice card), and +//! `get_session_info` (resolve a referenced session by id), gated by the +//! `--features` groups (`delegation` / `feedback` / `ask` / `sessions`). //! //! The agent's MCP config (injected by codeg via `load_mcp_servers_for_agent`) //! spawns this binary with three required flags: @@ -46,9 +48,10 @@ struct Args { /// upgrade failure) or hold open a UDS / pipe nobody will ever read /// from. Omitted by older parents — backward compatible. parent_pid: Option, - /// Comma-joined tool groups to expose (e.g. `delegation,feedback`). Omitted - /// by parents that predate feature gating; see `CompanionFeatures::parse` - /// (defaults to delegation-only). + /// Comma-joined tool groups to expose (e.g. + /// `delegation,feedback,ask,sessions`). Omitted by parents that predate + /// feature gating; see `CompanionFeatures::parse` (defaults to + /// delegation-only). features: Option, } @@ -97,7 +100,7 @@ fn parse_args() -> Result { } "--help" | "-h" => { println!( - "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback]" + "codeg-mcp --parent-connection-id --socket-path --token [--parent-pid ] [--features delegation,feedback,ask,sessions]" ); std::process::exit(0); } diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index c5c8ef2d7..7905cfdad 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -233,6 +233,7 @@ async fn async_main() { delegation_socket_path, feedback_config, question_config, + session_info_config, ) = codeg_lib::app_state::build_delegation_stack( &connection_manager, db.conn.clone(), @@ -257,6 +258,7 @@ async fn async_main() { delegation_socket_path: delegation_socket_path.clone(), feedback_config: feedback_config.clone(), question_config: question_config.clone(), + session_info_config: session_info_config.clone(), system_op_lock: codeg_lib::app_state::default_system_op_lock(), update_state: codeg_lib::app_state::default_update_state(), }); @@ -281,6 +283,12 @@ async fn async_main() { &question_config, ) .await; + // Same for the get-session-info enable flag. + codeg_lib::commands::session_info::apply_persisted_session_info_config( + &state.db.conn, + &session_info_config, + ) + .await; // Spawn the delegation listener so companion processes can round-trip // through the broker. Path is PID-scoped, so the listener owns it for @@ -298,6 +306,11 @@ async fn async_main() { Arc::new(codeg_lib::acp::manager::ConnectionManagerQuestionLookup { manager: Arc::new(state.connection_manager.clone_ref()), }), + Arc::new(codeg_lib::commands::session_info::DbSessionInfoLookup::new( + Arc::new(codeg_lib::db::AppDatabase { + conn: state.db.conn.clone(), + }), + )), ); let socket = delegation_socket_path.clone(); tokio::spawn(async move { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0b30309dc..2717a5e98 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod quick_messages; pub mod remote_proxy; #[cfg(feature = "tauri-runtime")] pub mod remote_workspace; +pub mod session_info; pub mod system_settings; pub mod terminal; pub mod version_control; diff --git a/src-tauri/src/commands/session_info.rs b/src-tauri/src/commands/session_info.rs new file mode 100644 index 000000000..31358eb9c --- /dev/null +++ b/src-tauri/src/commands/session_info.rs @@ -0,0 +1,649 @@ +//! `get_session_info` backing logic + settings persistence. +//! +//! Two surfaces live here, mirroring `crate::commands::question`: +//! +//! * [`DbSessionInfoLookup`] — the production [`SessionInfoAccess`] impl the +//! delegation listener calls to resolve a referenced session +//! (`codeg://session/`) into metadata + token stats and, on demand, a +//! bounded compacted view of its recent messages. It reuses +//! [`get_folder_conversation_core`] (which reads the conversation row, uses +//! its bound `external_id` + `agent_type` to pick the right parser, and parses +//! the on-disk transcript off the runtime via `spawn_blocking`). +//! * The `session_info.enabled` settings knob (**default true**) — read at MCP +//! injection time via [`SessionInfoRuntimeConfig`]. Persist + apply + broadcast +//! follows the exact shape of the ask-question / feedback toggles. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use crate::acp::session_info::{ + SessionInfo, SessionInfoAccess, SessionInfoConfig, SessionInfoRuntimeConfig, SessionMessageItem, + SessionMessages, MAX_SESSION_MESSAGES, +}; +use crate::app_error::AppCommandError; +use crate::commands::conversations::get_folder_conversation_core; +use crate::db::service::{app_metadata_service, conversation_service, folder_service}; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; +use crate::models::message::{ContentBlock, MessageTurn, TurnRole}; +use crate::web::event_bridge::{emit_event, EventEmitter, SESSION_INFO_SETTINGS_CHANGED_EVENT}; + +/// Upper bound on a single compacted turn's text. Keeps one chatty turn from +/// dominating the budget; longer text is truncated with an ellipsis marker. +const PER_TURN_CHARS: usize = 1_500; + +/// Overall character budget across all included turns (text AND tool names). +/// Walked newest-first so the most recent context survives; older turns are +/// dropped once this is exhausted. +const OVERALL_CHARS: usize = 16_000; + +/// Per-turn caps on the collected tool names, so a turn with thousands of +/// `ToolUse` blocks (or one with a pathologically long tool name) can't inflate +/// the payload past the text budget. Tool-name chars are ALSO charged against +/// [`OVERALL_CHARS`]. +const MAX_TOOLS_PER_TURN: usize = 16; +const MAX_TOOL_NAME_CHARS: usize = 64; + +/// How long the transcript parse may run before the resolver gives up and returns +/// metadata only. The session keeps existing; the agent simply gets no messages +/// this call (with a `note` explaining why). Generous for large session files, +/// bounded so a pathological file can't pin the round-trip. +/// +/// NOTE: this bounds the *response latency*, not the underlying work — the parse +/// runs on tokio's blocking pool (`spawn_blocking` inside +/// `get_folder_conversation_core`), which cannot be canceled, so a timed-out parse +/// keeps running to completion in the background. [`MAX_CONCURRENT_PARSES`] is what +/// actually protects the blocking pool from a flood of large-session reads. +const PARSE_TIMEOUT: Duration = Duration::from_secs(8); + +/// Cap on concurrent transcript parses across all `get_session_info` calls. A +/// small bound keeps a burst of reads on large sessions from saturating tokio's +/// blocking pool (each parse pins one blocking thread, uncancelable). Excess +/// concurrent callers do NOT queue — `bounded_parse` uses a non-blocking +/// `try_acquire`, so a call that finds every slot taken returns `Busy` and +/// degrades to metadata-only immediately. +const MAX_CONCURRENT_PARSES: usize = 4; + +/// Production [`SessionInfoAccess`]: resolves a session by codeg conversation id +/// against the DB + on-disk transcript. Wraps an `Arc` (like +/// `DbDepthLookup` / `DbChildStatusLookup`) plus a semaphore bounding concurrent +/// transcript parses. Construct via [`DbSessionInfoLookup::new`]. +pub struct DbSessionInfoLookup { + pub db: Arc, + /// Limits concurrent `get_folder_conversation_core` parses (see + /// [`MAX_CONCURRENT_PARSES`]). + parse_limit: Arc, +} + +impl DbSessionInfoLookup { + pub fn new(db: Arc) -> Self { + Self { + db, + parse_limit: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_PARSES)), + } + } +} + +#[async_trait] +impl SessionInfoAccess for DbSessionInfoLookup { + async fn resolve(&self, session_id: i32, max_messages: u32) -> SessionInfo { + let conn = &self.db.conn; + // get_by_id is the authoritative existence + metadata source (cheap PK + // lookup, already filters soft-deleted). Resolve it FIRST so a missing + // session is cleanly "not found" rather than conflated with a parse error. + let summary = match conversation_service::get_by_id(conn, session_id).await { + Ok(s) => s, + Err(_) => return SessionInfo::not_found(session_id), + }; + + let (workspace_path, workspace_name) = + match folder_service::get_folder_by_id(conn, summary.folder_id).await { + Ok(Some(folder)) => (Some(folder.path), Some(folder.name)), + _ => (None, None), + }; + + let mut info = SessionInfo { + found: true, + session_id: summary.id, + external_id: summary.external_id.clone(), + agent_type: Some(agent_type_str(summary.agent_type)), + title: summary.title.clone().filter(|t| !t.trim().is_empty()), + status: Some(summary.status.clone()), + model: summary.model.clone(), + git_branch: summary.git_branch.clone(), + workspace_path, + workspace_name, + message_count: Some(summary.message_count), + created_at: Some(summary.created_at), + updated_at: Some(summary.updated_at), + parent_id: summary.parent_id, + is_delegation_child: summary.parent_id.is_some(), + stats: None, + messages: None, + note: None, + }; + + if max_messages == 0 { + return info; + } + let max = max_messages.min(MAX_SESSION_MESSAGES); + + // Parse the transcript off the hot path, gated by `parse_limit` and bounded + // by PARSE_TIMEOUT (see `bounded_parse`). A busy / timeout / parse error all + // degrade to metadata-only with an explanatory note rather than failing the + // whole tool call. + let conn_owned = self.db.conn.clone(); + let parse = + async move { get_folder_conversation_core(&conn_owned, session_id).await }; + match bounded_parse(self.parse_limit.clone(), PARSE_TIMEOUT, parse).await { + ParseSlot::Ready(Ok((detail, parsed_title))) => { + if info.title.is_none() { + info.title = parsed_title.filter(|t| !t.trim().is_empty()); + } + // get_folder_conversation_core sets summary.message_count to the + // parsed turn count — more accurate than the stored row. + info.message_count = Some(detail.summary.message_count); + info.stats = detail.session_stats; + info.messages = Some(compact_turns(&detail.turns, max)); + } + ParseSlot::Ready(Err(_)) => { + info.note = Some( + "Recent messages are unavailable — the session transcript could not be parsed." + .to_string(), + ); + } + ParseSlot::Busy => { + info.note = Some( + "Recent messages are unavailable — too many session reads are in progress. \ + Retry, or call again with max_messages: 0 for metadata only." + .to_string(), + ); + } + ParseSlot::TimedOut => { + info.note = Some( + "Recent messages are unavailable — reading the session transcript timed out. \ + Retry, or call again with max_messages: 0 for metadata only." + .to_string(), + ); + } + } + info + } +} + +/// Outcome of a permit-gated, timeout-bounded parse (see [`bounded_parse`]). +enum ParseSlot { + /// The parse completed within the timeout; carries its result. + Ready(T), + /// No parse slot was free — work was NOT started (caller should degrade now). + Busy, + /// A slot was taken and the parse started, but the caller's wait elapsed. The + /// detached worker keeps its permit until the (uncancelable) work finishes, so + /// the slot count keeps bounding real blocking concurrency. + TimedOut, +} + +/// Run `parse` under a real concurrency bound: acquire one of `parse_limit`'s +/// permits with a NON-blocking `try_acquire` (so a flood returns [`ParseSlot::Busy`] +/// instead of queueing), spawn the work into a detached task that holds the permit +/// until `parse` actually returns, and wait for it for at most `timeout`. +/// +/// The permit lives with the spawned task — NOT with the caller's `timeout` future +/// — so when the caller gives up ([`ParseSlot::TimedOut`]) the permit stays held +/// until the underlying (uncancelable `spawn_blocking`) parse completes. That is +/// what makes `MAX_CONCURRENT_PARSES` bound the number of in-flight blocking parses +/// even under repeated timeouts, rather than just the number of waiting callers. +async fn bounded_parse( + parse_limit: Arc, + timeout: Duration, + parse: Fut, +) -> ParseSlot +where + Fut: std::future::Future + Send + 'static, + T: Send + 'static, +{ + // try_acquire (not acquire): don't queue behind busy parses — fail fast so the + // caller can return metadata-only immediately instead of piling up tasks. + let Ok(permit) = parse_limit.try_acquire_owned() else { + return ParseSlot::Busy; + }; + let handle = tokio::spawn(async move { + // Hold the permit for the WHOLE parse; released only when `parse` returns. + let _permit = permit; + parse.await + }); + match tokio::time::timeout(timeout, handle).await { + Ok(Ok(value)) => ParseSlot::Ready(value), + // Task panicked — treat as a failed parse (no value). + Ok(Err(_join)) => ParseSlot::TimedOut, + // Caller's wait elapsed; the detached task keeps running with its permit. + Err(_) => ParseSlot::TimedOut, + } +} + +/// Snake_case wire form of an [`AgentType`] (e.g. `claude_code`) — matches the +/// `conversation.agent_type` column and the frontend's `ALL_AGENT_TYPES`. +fn agent_type_str(at: AgentType) -> String { + serde_json::to_value(at) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default() +} + +/// Compact the most recent `max` turns into [`SessionMessages`]. Walks the turns +/// newest-first so the freshest context is kept under [`OVERALL_CHARS`], always +/// retaining at least the newest turn, then restores chronological order. +fn compact_turns(turns: &[MessageTurn], max: u32) -> SessionMessages { + let total = turns.len(); + let max = max as usize; + let mut items: Vec = Vec::new(); + let mut budget = OVERALL_CHARS; + + for turn in turns.iter().rev() { + if items.len() >= max { + break; + } + let item = compact_turn(turn); + // Charge BOTH the text and the (bounded) tool names against the budget so + // a turn can't smuggle an oversized payload through `tools`. + let cost = item.text.chars().count() + + item + .tools + .iter() + .map(|t| t.chars().count()) + .sum::(); + // Always keep the newest turn; stop once the budget can't fit the next. + if !items.is_empty() && cost > budget { + break; + } + budget = budget.saturating_sub(cost); + items.push(item); + } + items.reverse(); + + let included = items.len(); + SessionMessages { + total: total as u32, + included: included as u32, + truncated: included < total, + items, + } +} + +/// One turn → role + truncated text (Text/Thinking blocks) + tool names. +fn compact_turn(turn: &MessageTurn) -> SessionMessageItem { + let role = match turn.role { + TurnRole::User => "user", + TurnRole::Assistant => "assistant", + TurnRole::System => "system", + } + .to_string(); + + let mut parts: Vec<&str> = Vec::new(); + let mut tools: Vec = Vec::new(); + for block in &turn.blocks { + match block { + ContentBlock::Text { text } | ContentBlock::Thinking { text } => { + let t = text.trim(); + if !t.is_empty() { + parts.push(t); + } + } + ContentBlock::ToolUse { tool_name, .. } => { + // Bound the count per turn and each name's length; dedup on the + // truncated form so the collected `tools` can't exceed + // MAX_TOOLS_PER_TURN * MAX_TOOL_NAME_CHARS. + if tools.len() < MAX_TOOLS_PER_TURN && !tool_name.is_empty() { + let name = truncate_chars(tool_name, MAX_TOOL_NAME_CHARS); + if !tools.contains(&name) { + tools.push(name); + } + } + } + // Tool results, images, and image-generation carry no useful plain + // text for a compact preview — skipped to keep the payload lean. + _ => {} + } + } + let text = truncate_chars(&parts.join("\n"), PER_TURN_CHARS); + SessionMessageItem { role, text, tools } +} + +/// Truncate to at most `cap` characters, appending an ellipsis marker when cut. +fn truncate_chars(s: &str, cap: usize) -> String { + if s.chars().count() <= cap { + return s.to_string(); + } + let mut out: String = s.chars().take(cap).collect(); + out.push('…'); + out +} + +// =========================================================================== +// Settings persistence — `session_info.enabled` (default ON). Mirrors +// `crate::commands::question`. +// =========================================================================== + +pub const KEY_SESSION_INFO_ENABLED: &str = "session_info.enabled"; + +/// On by default (`enabled: true`). The `get_session_info` tool is read-only and +/// only fires when the user references a session, so — like `ask_user_question` — +/// it ships enabled; a user who never wants agents reading session data can turn +/// it off in settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionInfoSettings { + pub enabled: bool, +} + +impl Default for SessionInfoSettings { + fn default() -> Self { + Self { enabled: true } + } +} + +impl SessionInfoSettings { + fn into_runtime_config(self) -> SessionInfoConfig { + SessionInfoConfig { + enabled: self.enabled, + } + } +} + +/// Read the persisted key from `app_metadata`, falling back to the default +/// (enabled) for a missing or malformed value. Never errors hard. +pub async fn load_session_info_settings(conn: &DatabaseConnection) -> SessionInfoSettings { + let mut settings = SessionInfoSettings::default(); + if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_SESSION_INFO_ENABLED).await { + if let Ok(v) = raw.parse::() { + settings.enabled = v; + } + } + settings +} + +/// Pull settings from the DB and push the resulting `SessionInfoConfig` onto the +/// shared runtime handle. Idempotent — safe on startup or after any save. +pub async fn apply_persisted_session_info_config( + conn: &DatabaseConnection, + config: &SessionInfoRuntimeConfig, +) { + let settings = load_session_info_settings(conn).await; + config.set(settings.into_runtime_config()).await; +} + +/// Persist + apply + broadcast. Shared by the Tauri command and the HTTP handler +/// so the write + re-apply + notify chain lives in one place. +pub async fn set_session_info_settings_core( + conn: &DatabaseConnection, + config: &SessionInfoRuntimeConfig, + emitter: &EventEmitter, + desired: SessionInfoSettings, +) -> Result { + app_metadata_service::upsert_value( + conn, + KEY_SESSION_INFO_ENABLED, + &desired.enabled.to_string(), + ) + .await + .map_err(AppCommandError::from)?; + config.set(desired.clone().into_runtime_config()).await; + emit_event(emitter, SESSION_INFO_SETTINGS_CHANGED_EVENT, &desired); + Ok(desired) +} + +// -------- Tauri commands ----------------------------------------------------- + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_session_info_settings( + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + Ok(load_session_info_settings(&db.conn).await) + } + #[cfg(not(feature = "tauri-runtime"))] + { + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn set_session_info_settings( + #[cfg(feature = "tauri-runtime")] app: tauri::AppHandle, + #[cfg(feature = "tauri-runtime")] db: tauri::State<'_, crate::db::AppDatabase>, + #[cfg(feature = "tauri-runtime")] config: tauri::State<'_, SessionInfoRuntimeConfig>, + settings: SessionInfoSettings, +) -> Result { + #[cfg(feature = "tauri-runtime")] + { + let emitter = EventEmitter::Tauri(app); + set_session_info_settings_core(&db.conn, &config, &emitter, settings).await + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = settings; + Err(AppCommandError::configuration_invalid("tauri-only command")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use crate::models::message::ContentBlock; + + fn turn(role: TurnRole, blocks: Vec) -> MessageTurn { + MessageTurn { + id: "t".into(), + role, + blocks, + timestamp: Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + } + } + + #[test] + fn compact_turn_joins_text_and_collects_tools() { + let item = compact_turn(&turn( + TurnRole::Assistant, + vec![ + ContentBlock::Text { text: "hello".into() }, + ContentBlock::ToolUse { + tool_use_id: None, + tool_name: "Read".into(), + input_preview: None, + meta: None, + }, + ContentBlock::ToolUse { + tool_use_id: None, + tool_name: "Read".into(), + input_preview: None, + meta: None, + }, + ContentBlock::Thinking { text: "hmm".into() }, + ], + )); + assert_eq!(item.role, "assistant"); + assert_eq!(item.text, "hello\nhmm"); + assert_eq!(item.tools, vec!["Read".to_string()]); // deduped + } + + #[test] + fn compact_turns_keeps_most_recent_within_max() { + let turns: Vec = (0..5) + .map(|i| { + turn( + TurnRole::User, + vec![ContentBlock::Text { + text: format!("msg{i}"), + }], + ) + }) + .collect(); + let out = compact_turns(&turns, 2); + assert_eq!(out.total, 5); + assert_eq!(out.included, 2); + assert!(out.truncated); + // Chronological order preserved, newest two kept. + assert_eq!(out.items[0].text, "msg3"); + assert_eq!(out.items[1].text, "msg4"); + } + + #[test] + fn compact_turn_bounds_tool_names() { + // A turn with thousands of distinct, long tool names must collapse to at + // most MAX_TOOLS_PER_TURN names, each ≤ MAX_TOOL_NAME_CHARS. + let blocks: Vec = (0..5000) + .map(|i| ContentBlock::ToolUse { + tool_use_id: None, + tool_name: format!("{}_{i}", "x".repeat(500)), + input_preview: None, + meta: None, + }) + .collect(); + let item = compact_turn(&turn(TurnRole::Assistant, blocks)); + assert!(item.tools.len() <= MAX_TOOLS_PER_TURN); + for name in &item.tools { + assert!(name.chars().count() <= MAX_TOOL_NAME_CHARS + 1); // +1 for the ellipsis + } + } + + #[test] + fn compact_turns_charges_tools_against_budget() { + // Many turns, each carrying the max tool payload, must still be bounded: + // the budget accounts for tool-name chars, so `included` stays small and + // `truncated` is set rather than returning every turn. + let one_turn = || { + let blocks: Vec = (0..MAX_TOOLS_PER_TURN) + .map(|i| ContentBlock::ToolUse { + tool_use_id: None, + tool_name: format!("{}_{i}", "t".repeat(MAX_TOOL_NAME_CHARS)), + input_preview: None, + meta: None, + }) + .collect(); + turn(TurnRole::Assistant, blocks) + }; + let turns: Vec = (0..500).map(|_| one_turn()).collect(); + let out = compact_turns(&turns, 500); + // Total payload chars (text + tools) stays within ~one turn of the budget. + let total_chars: usize = out + .items + .iter() + .map(|i| { + i.text.chars().count() + + i.tools.iter().map(|t| t.chars().count()).sum::() + }) + .sum(); + assert!(total_chars <= OVERALL_CHARS + PER_TURN_CHARS); + assert!(out.truncated); + assert!((out.included as usize) < out.total as usize); + } + + #[test] + fn compact_turns_not_truncated_when_all_fit() { + let turns = vec![turn( + TurnRole::User, + vec![ContentBlock::Text { text: "only".into() }], + )]; + let out = compact_turns(&turns, 20); + assert_eq!(out.total, 1); + assert_eq!(out.included, 1); + assert!(!out.truncated); + } + + #[test] + fn truncate_chars_marks_when_cut() { + assert_eq!(truncate_chars("abc", 5), "abc"); + assert_eq!(truncate_chars("abcdef", 3), "abc…"); + } + + #[test] + fn agent_type_str_is_snake_case() { + assert_eq!(agent_type_str(AgentType::ClaudeCode), "claude_code"); + assert_eq!(agent_type_str(AgentType::OpenClaw), "open_claw"); + } + + #[tokio::test] + async fn bounded_parse_returns_value_within_timeout() { + let sem = Arc::new(tokio::sync::Semaphore::new(1)); + let out = bounded_parse(sem.clone(), Duration::from_secs(5), async { 7 }).await; + assert!(matches!(out, ParseSlot::Ready(7))); + // The permit is released once the work completes. + assert_eq!(sem.available_permits(), 1); + } + + #[tokio::test] + async fn bounded_parse_reports_busy_when_no_slot_free() { + // A semaphore with zero permits → no slot → Busy, work never starts. + let sem = Arc::new(tokio::sync::Semaphore::new(0)); + let out: ParseSlot = + bounded_parse(sem, Duration::from_secs(5), async { 1 }).await; + assert!(matches!(out, ParseSlot::Busy)); + } + + /// Regression for the review finding: a timed-out parse must keep occupying its + /// slot until the underlying (uncancelable) work actually finishes — NOT be + /// released the moment the caller gives up waiting. + #[tokio::test] + async fn bounded_parse_holds_permit_past_caller_timeout() { + let sem = Arc::new(tokio::sync::Semaphore::new(1)); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + // A "parse" that blocks until we signal it, modeling a slow spawn_blocking. + let parse = async move { + let _ = release_rx.await; + 99 + }; + // Caller waits only briefly while the work is still blocked → TimedOut. + let out = bounded_parse(sem.clone(), Duration::from_millis(50), parse).await; + assert!(matches!(out, ParseSlot::TimedOut)); + // The permit is STILL held by the detached worker (work hasn't finished). + assert_eq!(sem.available_permits(), 0); + assert!(sem.try_acquire().is_err()); + + // Now let the work complete; the worker drops the permit. + release_tx.send(()).unwrap(); + // Await the permit returning (generous bound; no fixed sleep) to prove it + // is released only after the underlying work finishes. + let _permit = tokio::time::timeout(Duration::from_secs(2), sem.acquire()) + .await + .expect("permit must be released once the parse completes") + .unwrap(); + } + + #[tokio::test] + async fn settings_default_on_and_round_trip() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + assert!(load_session_info_settings(&db.conn).await.enabled); + + let config = SessionInfoRuntimeConfig::new(); + set_session_info_settings_core( + &db.conn, + &config, + &EventEmitter::Noop, + SessionInfoSettings { enabled: false }, + ) + .await + .unwrap(); + assert!(!load_session_info_settings(&db.conn).await.enabled); + assert!(!config.is_enabled().await); + } + + #[tokio::test] + async fn resolve_unknown_id_is_not_found() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let lookup = DbSessionInfoLookup::new(Arc::new(AppDatabase { + conn: db.conn.clone(), + })); + let info = lookup.resolve(999_999, 10).await; + assert!(!info.found); + assert_eq!(info.session_id, 999_999); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60877cb43..ef206bc20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,7 +50,8 @@ mod tauri_app { model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, - remote_workspace as remote_workspace_commands, system_settings, terminal as terminal_commands, + remote_workspace as remote_workspace_commands, session_info as session_info_commands, + system_settings, terminal as terminal_commands, version_control, windows, workspace_state as workspace_state_commands, }; use crate::terminal::manager::TerminalManager; @@ -436,26 +437,34 @@ mod tauri_app { let broker_for_lifecycle = { let cm_state = app.state::(); let db_conn = app.state::().conn.clone(); - let (broker, tokens, socket_path, feedback_config, question_config) = - crate::app_state::build_delegation_stack( - &cm_state, - db_conn.clone(), - effective_data_dir.clone(), - ); + let ( + broker, + tokens, + socket_path, + feedback_config, + question_config, + session_info_config, + ) = crate::app_state::build_delegation_stack( + &cm_state, + db_conn.clone(), + effective_data_dir.clone(), + ); app.manage(broker.clone()); app.manage(tokens.clone()); app.manage(feedback_config.clone()); app.manage(question_config.clone()); + app.manage(session_info_config.clone()); app.manage(crate::commands::delegation::DelegationSocketPath( socket_path.clone(), )); // Push persisted settings into the broker + feedback + question - // config before listener accept. + // + session-info config before listener accept. let broker_for_init = broker.clone(); let db_for_init = db_conn.clone(); let feedback_for_init = feedback_config.clone(); let question_for_init = question_config.clone(); + let session_info_for_init = session_info_config.clone(); tauri::async_runtime::block_on(async move { delegation_commands::apply_persisted_config( &db_for_init, @@ -472,6 +481,11 @@ mod tauri_app { &question_for_init, ) .await; + crate::commands::session_info::apply_persisted_session_info_config( + &db_for_init, + &session_info_for_init, + ) + .await; }); let listener_broker = broker.clone(); @@ -493,6 +507,13 @@ mod tauri_app { manager: std::sync::Arc::new(cm_state.clone_ref()), }, ), + std::sync::Arc::new( + crate::commands::session_info::DbSessionInfoLookup::new( + std::sync::Arc::new(db::AppDatabase { + conn: db_conn.clone(), + }), + ), + ), ); tauri::async_runtime::spawn(async move { if let Err(e) = listener.run(socket_path).await { @@ -929,6 +950,8 @@ mod tauri_app { feedback_commands::submit_session_feedback, question_commands::get_question_settings, question_commands::set_question_settings, + session_info_commands::get_session_info_settings, + session_info_commands::set_session_info_settings, version_control::detect_git, version_control::test_git_path, version_control::get_git_settings, diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 0a227d117..058cfe85b 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -7,7 +7,9 @@ use chrono::{DateTime, Utc}; use regex::Regex; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; /// Regex that matches Claude Code system-injected XML tags and their content. /// These tags are internal metadata and should not be displayed to users. @@ -434,7 +436,7 @@ impl ClaudeParser { // Extract title from first user message if msg_type == "user" && title.is_none() { - title = extract_user_text(&value).map(|t| truncate_str(&t, 100)); + title = extract_user_text(&value).map(|t| title_from_user_text(&t)); } } } @@ -724,7 +726,7 @@ impl ClaudeParser { ContentBlock::Text { text } => Some(text.clone()), _ => None, }) { - title = Some(truncate_str(&first_text, 100)); + title = Some(title_from_user_text(&first_text)); } } MessageRole::User diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index be145708c..817fbd365 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -9,7 +9,10 @@ use crate::models::{ TurnUsage, }; -use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError}; +use super::{ + compute_session_stats, folder_name_from_path, title_from_user_text, truncate_str, AgentParser, + ParseError, +}; // --------------------------------------------------------------------------- // On-disk JSON structures @@ -158,7 +161,7 @@ impl AgentParser for ClineParser { let folder_path = entry.cwd_on_task_initialization.clone(); let folder_name = folder_path.as_deref().map(folder_name_from_path); - let title = entry.task.as_deref().map(|t| truncate_str(t.trim(), 100)); + let title = entry.task.as_deref().map(|t| title_from_user_text(t.trim())); // Count messages from api_conversation_history.json let api_path = tasks_dir.join("api_conversation_history.json"); @@ -239,7 +242,7 @@ impl AgentParser for ClineParser { let title = history_entry .as_ref() .and_then(|e| e.task.as_deref()) - .map(|t| truncate_str(t.trim(), 100)); + .map(|t| title_from_user_text(t.trim())); let mut turns: Vec = Vec::new(); let mut turn_counter = 0u32; diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index 71808c72e..8a4684ed0 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -9,7 +9,9 @@ use regex::Regex; use walkdir::WalkDir; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; pub struct CodexParser { base_dir: PathBuf, @@ -1593,7 +1595,7 @@ fn extract_codex_title_candidate(input: &str, fallback_attached: bool) -> Option None } } else { - Some(truncate_str(&cleaned, 100)) + Some(title_from_user_text(&cleaned)) } } diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 7c42a00f4..fdc37babf 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -7,7 +7,9 @@ use serde_json::{Map, Value}; use walkdir::WalkDir; use crate::models::*; -use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError}; +use crate::parsers::{ + folder_name_from_path, title_from_user_text, truncate_str, AgentParser, ParseError, +}; pub struct GeminiParser { base_dir: PathBuf, @@ -419,7 +421,7 @@ impl GeminiParser { .filter(|m| m.get("type").and_then(|t| t.as_str()) == Some("user")) .filter_map(Self::extract_message_text) .find(|t| !t.trim_start().starts_with(" String { } } +/// Punctuation the serializer escapes with a leading backslash inside a +/// reference label (mirrors `escapeMarkdownText` in `src/lib/reference-text.ts` +/// and the class in the frontend `unescapeReferenceLabel`). +fn is_escapable_reference_punct(c: char) -> bool { + matches!( + c, + '\\' | '`' | '*' | '_' | '~' | '[' | ']' | '(' | ')' | '<' | '>' + ) +} + +/// Reverse the serializer's label escaping: drop the backslash from each escaped +/// inline-significant punctuation char so the recovered label reads literally. +/// Mirrors the frontend `unescapeReferenceLabel`. +fn unescape_reference_label(label: &[char]) -> String { + let mut out = String::with_capacity(label.len()); + let mut i = 0; + while i < label.len() { + if label[i] == '\\' && i + 1 < label.len() && is_escapable_reference_punct(label[i + 1]) { + out.push(label[i + 1]); + i += 2; + } else { + out.push(label[i]); + i += 1; + } + } + out +} + +/// Mirror ECMAScript's `/\s/` — the whitespace class the frontend +/// `foldReferenceLinks` (`src/lib/reference-link.ts`) scans destinations with — +/// so this port stays in step with it. It deliberately differs from Rust's +/// `char::is_whitespace()` in exactly two code points: `U+FEFF` (BOM) is +/// whitespace to JS but not to Rust, and `U+0085` (NEL) is whitespace to Rust +/// but not to JS. The set is ECMAScript WhiteSpace + LineTerminator. +fn is_markdown_whitespace(c: char) -> bool { + matches!( + c, + '\u{0009}'..='\u{000D}' // tab, LF, VT, FF, CR + | '\u{0020}' // space + | '\u{00A0}' // no-break space + | '\u{1680}' + | '\u{2000}'..='\u{200A}' + | '\u{2028}' // line separator + | '\u{2029}' // paragraph separator + | '\u{202F}' + | '\u{205F}' + | '\u{3000}' + | '\u{FEFF}' // zero-width no-break space (BOM) + ) +} + +/// Whether the backslash at `k` escapes the next character. CommonMark never +/// lets a backslash escape whitespace, so `\` + whitespace ENDS (not extends) a +/// label/destination scan — only `\` + a non-whitespace char is a real escape. +fn reference_escapes_next(chars: &[char], k: usize) -> bool { + chars.get(k) == Some(&'\\') && chars.get(k + 1).is_some_and(|c| !is_markdown_whitespace(*c)) +} + +/// If a well-formed `(destination)` begins at `start`, return the index just +/// past its closing `)`; otherwise `None`. Mirrors the frontend `destinationEnd` +/// and the serializer's two forms: a `<…>`-wrapped destination (interior `\`, +/// `<`, `>` backslash-escaped) or a bare run with no `(`, `)`, whitespace, `<` or +/// `>`. +fn reference_destination_end(chars: &[char], start: usize) -> Option { + let n = chars.len(); + if start >= n || chars[start] != '(' { + return None; + } + let mut k = start + 1; + if chars.get(k) == Some(&'<') { + k += 1; + while k < n { + if reference_escapes_next(chars, k) { + k += 2; + continue; + } + match chars[k] { + '>' => { + return if chars.get(k + 1) == Some(&')') { + Some(k + 2) + } else { + None + }; + } + // An unescaped `<` or a line break is forbidden inside `<…>`; + // bailing here also bounds the scan so a missing `>` stops at the + // next `<` instead of running to EOF (keeps adversarial input + // linear). + '<' | '\n' | '\r' => return None, + _ => k += 1, + } + } + return None; + } + while k < n { + if reference_escapes_next(chars, k) { + k += 2; + continue; + } + let c = chars[k]; + if c == ')' { + return Some(k + 1); + } + if c == '(' || c == '<' || c == '>' || is_markdown_whitespace(c) { + return None; + } + k += 1; + } + None +} + +/// Replace every inline `[label](destination)` reference link in `text` with its +/// unescaped `label`, leaving all other prose (including malformed `[…]`/`(…)` +/// fragments and invocation tokens like `@Codex`) untouched. +/// +/// This is the Rust counterpart of the frontend canonical fold +/// (`foldReferenceLinks` in `src/lib/reference-link.ts`) and MUST stay in step +/// with it: a single O(n) left-to-right scan over a stack of unmatched `[` +/// positions, matching each `]` against the most recent opener so a balanced +/// nested label closes at the right bracket, requiring a non-empty label and a +/// well-formed `(dest)` for a link, and recovering later links after a +/// stray/unbalanced `[`. Used to derive conversation titles from a user's first +/// message: folding BEFORE truncation means a long `file://` destination can +/// never be sliced mid-link into an unterminable `[label](file://…` fragment. +pub fn fold_reference_links(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(text.len()); + // Start of the pending prose run; flushed before each link and at the end. + let mut text_start = 0usize; + // Indices of `[` seen but not yet matched by a `]` (most recent on top). + let mut openers: Vec = Vec::new(); + let mut i = 0usize; + + while i < n { + if reference_escapes_next(&chars, i) { + // `\[` / `\]` (and any `\x`) is literal — skip both chars. + i += 2; + continue; + } + match chars[i] { + '[' => { + openers.push(i); + i += 1; + } + ']' if !openers.is_empty() => { + let open = openers.pop().expect("openers is non-empty"); + match reference_destination_end(&chars, i + 1) { + // A link needs a well-formed `(dest)` right after `]` and a + // non-empty label between the brackets. + Some(end) if i > open + 1 => { + out.extend(chars[text_start..open].iter()); + out.push_str(&unescape_reference_label(&chars[open + 1..i])); + // Everything up to `open` is committed, so any still-open + // outer `[` can no longer span a link. + openers.clear(); + i = end; + text_start = end; + } + // Not a link: keep the brackets in the pending prose run and + // keep scanning so a later valid link is still found. + _ => i += 1, + } + } + _ => i += 1, + } + } + out.extend(chars[text_start..n].iter()); + out +} + +/// Derive a conversation title from a user's first message: fold inline +/// reference links to their labels, then cap the length. Folding first ensures a +/// `[name](file://)` mention becomes `name` instead of a raw — and, +/// once truncated, unterminable — Markdown link. +pub fn title_from_user_text(text: &str) -> String { + truncate_str(&fold_reference_links(text), 100) +} + /// Aggregate turn-level usage and duration into a single `SessionStats`. pub fn compute_session_stats(turns: &[MessageTurn]) -> Option { let mut total_in = 0u64; @@ -785,11 +964,101 @@ mod tests { use chrono::Utc; use super::{ - infer_context_window_max_tokens, latest_turn_total_usage_tokens, - merge_context_window_stats, path_eq_for_matching, + fold_reference_links, infer_context_window_max_tokens, latest_turn_total_usage_tokens, + merge_context_window_stats, path_eq_for_matching, title_from_user_text, }; use crate::models::{MessageTurn, SessionStats, TurnRole, TurnUsage}; + #[test] + fn fold_reference_links_reduces_links_to_labels() { + // Plain prose is untouched. + assert_eq!(fold_reference_links("hello world"), "hello world"); + // A file link folds to its label; surrounding text is preserved. + assert_eq!( + fold_reference_links("看看 [README.md](file:///Users/x/README.md) 这是什么"), + "看看 README.md 这是什么" + ); + // codeg:// links fold too; an agent mention keeps its `@`. + assert_eq!( + fold_reference_links("调用 [@Codex CLI](codeg://agent/codex) 执行"), + "调用 @Codex CLI 执行" + ); + // Multiple links in one string. + assert_eq!( + fold_reference_links("compare [a.ts](file:///a.ts) and [b.ts](file:///b.ts)"), + "compare a.ts and b.ts" + ); + } + + #[test] + fn fold_reference_links_handles_escapes_and_angle_destinations() { + // A `<…>`-wrapped destination (spaces/parens in the path) still folds. + assert_eq!( + fold_reference_links("[report (1).pdf]()"), + "report (1).pdf" + ); + // Escaped punctuation in the label is unescaped. + assert_eq!(fold_reference_links("[a\\]b\\(c](file:///x)"), "a]b(c"); + // A balanced nested-bracket label closes at the outer `]`. + assert_eq!(fold_reference_links("[a [b]](https://x)"), "a [b]"); + // A later link is recovered after a stray/unbalanced `[`. + assert_eq!(fold_reference_links("[a [b](url)"), "[a b"); + } + + #[test] + fn fold_reference_links_matches_js_whitespace_class() { + // Parity with the frontend `foldReferenceLinks`, whose destination scan + // uses ECMAScript `/\s/` rather than Rust's `char::is_whitespace()`. The + // two classes differ on exactly these code points (verified against the + // TS module): U+FEFF (BOM) and U+00A0 (NBSP) ARE JS whitespace, so a bare + // destination containing them is malformed and the text stays raw… + assert_eq!( + fold_reference_links("[a](foo\u{FEFF}bar)"), + "[a](foo\u{FEFF}bar)" + ); + assert_eq!( + fold_reference_links("[a](foo\u{00A0}bar)"), + "[a](foo\u{00A0}bar)" + ); + // …while U+0085 (NEL) is NOT JS whitespace, so it is an ordinary + // destination char and the link folds (Rust's is_whitespace would have + // wrongly rejected it). + assert_eq!(fold_reference_links("[a](foo\u{0085}bar)"), "a"); + } + + #[test] + fn fold_reference_links_leaves_malformed_fragments_raw() { + // An unterminated link (no closing `)`) is left verbatim — exactly the + // truncated-title shape this fix keeps from ever being stored. + assert_eq!( + fold_reference_links("[oops no close](file:///x"), + "[oops no close](file:///x" + ); + // An empty-label `[](x)` is not a link. + assert_eq!(fold_reference_links("[](x)"), "[](x)"); + // A bare destination with an unescaped space is malformed. + assert_eq!(fold_reference_links("[a](foo bar)"), "[a](foo bar)"); + } + + #[test] + fn title_from_user_text_folds_before_truncating() { + // The regression: a long percent-encoded file mention used to be + // truncated mid-destination into an unterminable `[label](file://…` + // fragment. Folding first yields the short, clean filename. + let long_path = "%E5%85%A8".repeat(40); // > 100 chars when raw + let raw = format!("[全天候运维.xlsx](file:///Users/xggz/Desktop/{long_path}.xlsx)"); + assert!(raw.chars().count() > 100, "fixture must exceed the cap"); + assert_eq!(title_from_user_text(&raw), "全天候运维.xlsx"); + } + + #[test] + fn title_from_user_text_still_caps_plain_prose() { + let long = "x".repeat(250); + let title = title_from_user_text(&long); + assert_eq!(title.chars().count(), 103); // 100 + "..." + assert!(title.ends_with("...")); + } + #[test] fn infers_model_context_limits() { assert_eq!( diff --git a/src-tauri/src/parsers/openclaw.rs b/src-tauri/src/parsers/openclaw.rs index 7d9d8ff18..6946d8e9c 100644 --- a/src-tauri/src/parsers/openclaw.rs +++ b/src-tauri/src/parsers/openclaw.rs @@ -11,8 +11,8 @@ use serde::Deserialize; use crate::models::*; use crate::parsers::{ compute_session_stats, folder_name_from_path, infer_context_window_max_tokens, - latest_turn_total_usage_tokens, merge_context_window_stats, truncate_str, AgentParser, - ParseError, + latest_turn_total_usage_tokens, merge_context_window_stats, title_from_user_text, truncate_str, + AgentParser, ParseError, }; /// Regex to strip the "Sender (untrusted metadata):" block and optional @@ -450,7 +450,7 @@ impl OpenClawParser { if title.is_none() { let cleaned = strip_openclaw_user_prefix(&text); if !cleaned.is_empty() { - title = Some(truncate_str(&cleaned, 100)); + title = Some(title_from_user_text(&cleaned)); } } } @@ -603,7 +603,7 @@ impl OpenClawParser { } if title.is_none() { if let Some(ContentBlock::Text { ref text }) = content.first() { - title = Some(truncate_str(text, 100)); + title = Some(title_from_user_text(text)); } } messages.push(UnifiedMessage { diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index 4d996be39..c977b7d81 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -164,6 +164,12 @@ pub const FEEDBACK_SETTINGS_CHANGED_EVENT: &str = "feedback-settings://changed"; /// bool }`). pub const QUESTION_SETTINGS_CHANGED_EVENT: &str = "question-settings://changed"; +/// Global side-channel announcing a `get_session_info` enable/disable. Same +/// cross-window rationale as [`QUESTION_SETTINGS_CHANGED_EVENT`]: the settings UI +/// runs in a separate window, so other views learn the flag flipped only via this +/// backend broadcast. Payload: `SessionInfoSettings` (`{ "enabled": bool }`). +pub const SESSION_INFO_SETTINGS_CHANGED_EVENT: &str = "session-info-settings://changed"; + /// Payload for the global [`CONVERSATION_CHANGED_EVENT`] side-channel. Drives /// cross-client sidebar sync (membership + status) independent of the /// per-connection ACP attach protocol, so clients that are NOT attached to a diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 163023d41..e5bed632a 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -18,6 +18,7 @@ pub mod pet; pub mod project_boot; pub mod question; pub mod quick_messages; +pub mod session_info; pub mod system_settings; pub mod terminal; mod upload_jail; diff --git a/src-tauri/src/web/handlers/session_info.rs b/src-tauri/src/web/handlers/session_info.rs new file mode 100644 index 000000000..96db5b2e0 --- /dev/null +++ b/src-tauri/src/web/handlers/session_info.rs @@ -0,0 +1,42 @@ +//! HTTP handlers for get-session-info settings — the web-mode mirror of the +//! Tauri commands in `commands::session_info`. +//! +//! Both endpoints share the same core helpers (`load_session_info_settings`, +//! `set_session_info_settings_core`) so the persist + runtime-config re-apply +//! behavior stays identical across transports. + +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::session_info::{ + load_session_info_settings, set_session_info_settings_core, SessionInfoSettings, +}; + +pub async fn get_session_info_settings( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(load_session_info_settings(&state.db.conn).await)) +} + +#[derive(Deserialize)] +pub struct SetSessionInfoSettingsParams { + pub settings: SessionInfoSettings, +} + +pub async fn set_session_info_settings( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let saved = set_session_info_settings_core( + &state.db.conn, + &state.session_info_config, + &state.emitter, + params.settings, + ) + .await?; + Ok(Json(saved)) +} diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index d657cd9fb..8d57043f7 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -824,6 +824,12 @@ pub(crate) async fn do_start_web_server_tauri( .state::() .inner() .clone(), + // Reuse the same get-session-info config handle the desktop MCP injection + // reads, so HTTP-side session-info settings target the same flag. + session_info_config: app + .state::() + .inner() + .clone(), system_op_lock: crate::app_state::default_system_op_lock(), // Reuse the same handle the desktop `app_update` commands write to so // HTTP and webview readers see the identical update snapshot. diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c47432ee6..60ded77f7 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -84,6 +84,14 @@ pub fn build_router( "/set_question_settings", post(handlers::question::set_question_settings), ) + .route( + "/get_session_info_settings", + post(handlers::session_info::get_session_info_settings), + ) + .route( + "/set_session_info_settings", + post(handlers::session_info::set_session_info_settings), + ) .route( "/get_folder_conversation", post(handlers::conversations::get_folder_conversation), diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index ebf541237..703ef8bd3 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -64,6 +64,19 @@ impl codeg_lib::acp::feedback::SessionFeedbackAccess for NoFeedback { async fn commit_feedback_delivered(&self, _parent_connection_id: &str, _ids: Vec) {} } +/// No-op session-info access — this e2e suite never drives `get_session_info`. +struct NoSessionInfo; +#[async_trait] +impl codeg_lib::acp::session_info::SessionInfoAccess for NoSessionInfo { + async fn resolve( + &self, + session_id: i32, + _max_messages: u32, + ) -> codeg_lib::acp::session_info::SessionInfo { + codeg_lib::acp::session_info::SessionInfo::not_found(session_id) + } +} + /// Controllable question access for the ask round-trip test: `register_question` /// parks a sender keyed by a freshly-minted id; the test pops it via /// `take_pending` and resolves it, exactly as a user answering the card would. @@ -150,6 +163,7 @@ async fn end_to_end_uds_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); // PID-scoped socket inside the OS temp dir — no clashes across test bins. @@ -264,6 +278,7 @@ async fn end_to_end_uds_batch_status() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -349,6 +364,7 @@ async fn end_to_end_uds_invalid_token_rejected() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(StubQuestions::default()) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -413,6 +429,7 @@ async fn end_to_end_uds_ask_question_round_trip() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions.clone() as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); @@ -550,6 +567,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, questions as Arc, + Arc::new(NoSessionInfo) as Arc, ); let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tests/delegation_e2e_windows.rs b/src-tauri/tests/delegation_e2e_windows.rs index 512983cfc..44aff7264 100644 --- a/src-tauri/tests/delegation_e2e_windows.rs +++ b/src-tauri/tests/delegation_e2e_windows.rs @@ -70,6 +70,19 @@ impl SessionQuestionAccess for NoQuestions { async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} } +/// No-op session-info access — this e2e suite never drives `get_session_info`. +struct NoSessionInfo; +#[async_trait] +impl codeg_lib::acp::session_info::SessionInfoAccess for NoSessionInfo { + async fn resolve( + &self, + session_id: i32, + _max_messages: u32, + ) -> codeg_lib::acp::session_info::SessionInfo { + codeg_lib::acp::session_info::SessionInfo::not_found(session_id) + } +} + fn unique_pipe(tag: &str) -> String { format!( r"\\.\pipe\codeg-e2e-{}-{}-{}", @@ -157,6 +170,7 @@ async fn end_to_end_named_pipe_happy_path() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let pipe = unique_pipe("happy"); @@ -257,6 +271,7 @@ async fn end_to_end_named_pipe_back_to_back_requests() { Arc::new(FixedParent(1)) as Arc, Arc::new(NoFeedback) as Arc, Arc::new(NoQuestions) as Arc, + Arc::new(NoSessionInfo) as Arc, ); let pipe = unique_pipe("repeat"); diff --git a/src/app/globals.css b/src/app/globals.css index fa1b08d4c..97c5b80e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -11,11 +11,12 @@ @custom-variant dark (&:is(.dark *)); :root { - /* 默认界面字体 = 系统 UI 字体,运行时由 AppearanceProvider 覆盖 --font-sans。 - 会话消息区等普通文本均跟随 --font-sans;--font-mono 不在此自定义, - 由 Tailwind 默认等宽栈兜底(与代码编辑器字体相互独立)。 */ - --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", - Arial, sans-serif; + /* 默认界面字体 = 系统等宽字体(与 DEFAULT_UI_FONT_ID = "system-mono" 一致, + 须等于 font-presets.ts 的 MONO_FALLBACK,否则首屏到水合会闪字),运行时由 + AppearanceProvider 覆盖 --font-sans。会话消息区等普通文本均跟随 --font-sans; + --font-mono 不在此自定义,由 Tailwind 默认等宽栈兜底(与代码编辑器字体相互独立)。 */ + --font-sans: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; font-family: var(--font-sans); font-size: 16px; @@ -1457,3 +1458,115 @@ background-color: rgba(248, 81, 73, 0.35); } } + +/* ───────────────── Rich-text composer (Tiptap) ───────────────── + Live WYSIWYG Markdown styling for the message composer. Scoped to + `.codeg-composer` so it never leaks into Streamdown message rendering. */ + +/* The composer box reads as one editable surface: clicking its blank chrome + (padding, the dead space below a short message, the action-bar gaps) focuses + the editor, so the I-beam should show across all of it. `cursor` inherits, so + the contenteditable already gets `text`; interactive controls inside the box + re-assert a pointer (buttons carry no explicit cursor of their own). Disabled + buttons keep `pointer-events: none`, so the box's I-beam shows through them — + intended: clicking a disabled control still focuses the editor. */ +.codeg-composer-chrome { + cursor: text; +} +.codeg-composer-chrome :is(button, a[href], [role="button"], [role="menuitem"], [role="combobox"]) { + cursor: pointer; +} + +.codeg-composer .ProseMirror { + outline: none; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; +} + +/* Tight block rhythm — a chat input, not a document. */ +.codeg-composer .ProseMirror > * { + margin: 0; +} +.codeg-composer .ProseMirror > * + * { + margin-top: 0.5rem; +} + +.codeg-composer .ProseMirror :is(h1, h2, h3, h4) { + font-weight: 600; + line-height: 1.3; +} +.codeg-composer .ProseMirror h1 { + font-size: 1.25rem; +} +.codeg-composer .ProseMirror h2 { + font-size: 1.125rem; +} +.codeg-composer .ProseMirror :is(h3, h4) { + font-size: 1rem; +} + +.codeg-composer .ProseMirror :is(ul, ol) { + padding-left: 1.25rem; +} +.codeg-composer .ProseMirror ul { + list-style: disc; +} +.codeg-composer .ProseMirror ol { + list-style: decimal; +} +.codeg-composer .ProseMirror li > p { + margin: 0; +} + +.codeg-composer .ProseMirror blockquote { + border-left: 2px solid var(--border); + padding-left: 0.75rem; + color: var(--muted-foreground); +} + +.codeg-composer .ProseMirror code { + font-family: + var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.85em; + background: var(--muted); + padding: 0.1em 0.3em; + border-radius: 0.25rem; +} +.codeg-composer .ProseMirror pre { + background: var(--muted); + border-radius: 0.5rem; + padding: 0.6rem 0.75rem; + overflow-x: auto; +} +.codeg-composer .ProseMirror pre code { + background: transparent; + padding: 0; + font-size: 0.8125rem; +} + +.codeg-composer .ProseMirror a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Placeholder painted by @tiptap/extension-placeholder on the empty doc. */ +.codeg-composer .ProseMirror p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--muted-foreground); + float: left; + height: 0; + pointer-events: none; +} + +/* Inline reference badges (atom node view). */ +.codeg-composer .codeg-reference { + cursor: default; + user-select: none; + white-space: normal; +} +.codeg-composer .codeg-reference.ProseMirror-selectednode [data-reference-badge] { + outline: 2px solid var(--ring); + outline-offset: 1px; +} diff --git a/src/app/pet-panel/_components/SessionRow.tsx b/src/app/pet-panel/_components/SessionRow.tsx index 49e9f6afa..ca06210b9 100644 --- a/src/app/pet-panel/_components/SessionRow.tsx +++ b/src/app/pet-panel/_components/SessionRow.tsx @@ -10,6 +10,7 @@ import { type PetSessionStatusKind, } from "@/lib/pet/session-display" import { cn } from "@/lib/utils" +import { formatConversationTitle } from "@/lib/conversation-title" import { PanelPermissionCard } from "./PanelPermissionCard" interface SessionRowProps { @@ -57,7 +58,8 @@ export function SessionRow({ session }: SessionRowProps) { > - {session.title || AGENT_LABELS[session.agentType]} + {formatConversationTitle(session.title) || + AGENT_LABELS[session.agentType]} { expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() }) + it("jumps to the start line of a ranged file link (#L-)", async () => { + render() + + fireEvent.click(screen.getByRole("button", { name: "Trigger link" })) + + await waitFor(() => { + expect(mocks.openFilePreview).toHaveBeenCalledWith("src/app.ts", { + line: 10, + }) + }) + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument() + }) + it("blocks unsupported markdown link protocols without rendering a confirmation dialog", async () => { render() diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index 90ee0fb28..063907392 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -60,8 +60,10 @@ function parseLineValue(raw: string | undefined): number | null { function parseHashLine(hash: string): number | null { const normalized = hash.startsWith("#") ? hash.slice(1) : hash if (!normalized) return null + // `L` / `L-` / `L-L` (GitHub-style) — a range + // (e.g. the editor's "add selection" badge `#L10-25`) jumps to its start line. return ( - parseLineValue(normalized.match(/^L(\d+)$/i)?.[1]) ?? + parseLineValue(normalized.match(/^L(\d+)(?:-L?\d+)?$/i)?.[1]) ?? parseLineValue(normalized.match(/^line=(\d+)$/i)?.[1]) ?? parseLineValue(normalized.match(/^(\d+)$/)?.[1]) ) @@ -283,7 +285,13 @@ function DirectLinkOpen({ return null } -function useOpenLinkOrFile() { +/** + * Hook returning an async opener for a link or local-file uri: `file://` (and + * bare local paths) open in the workspace file panel; http(s)/mailto/tel route + * to the browser / OS handler. Used by the Streamdown link-safety modal and by + * standalone clickable file affordances (e.g. user-message resource badges). + */ +export function useOpenLinkOrFile() { const t = useTranslations("Folder.chat.linkSafety") const { activeFolder: folder } = useActiveFolder() const folderPath = folder?.path diff --git a/src/components/ai-elements/markdown-link.test.tsx b/src/components/ai-elements/markdown-link.test.tsx index 05ad1085f..b17565619 100644 --- a/src/components/ai-elements/markdown-link.test.tsx +++ b/src/components/ai-elements/markdown-link.test.tsx @@ -98,4 +98,103 @@ describe("MarkdownLink", () => { expect(mocks.onLinkCheck).not.toHaveBeenCalled() expect(screen.queryByTestId("link-modal")).not.toBeInTheDocument() }) + + describe("codeg:// reference badges", () => { + it("renders a session link as a session badge (conversation glyph, no agent icon or status dot)", () => { + render( + My chat + ) + // It's a badge, not a clickable link. + expect(screen.queryByRole("button")).toBeNull() + const badge = screen.getByRole("img", { name: "session: My chat" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "session") + // Even though the codex agent type is recoverable from the uri, the inline + // transcript badge shows the neutral conversation glyph — not the owning + // agent's icon (an AgentIcon svg would carry Codex) — and no + // trailing status dot. (User messages mirror the composer badge.) + expect(badge.querySelector(".lucide-message-square")).not.toBeNull() + expect(badge.querySelector("title")).toBeNull() + expect(badge.querySelector(".rounded-full")).toBeNull() + }) + + it("renders a legacy numeric session link as a session badge", () => { + render(Login) + const badge = screen.getByRole("img", { name: "session: Login" }) + expect(badge).toHaveAttribute("data-ref-type", "session") + }) + + it("renders a commit link as a commit badge", () => { + render( + + abc1234 + + ) + const badge = screen.getByRole("img", { name: "commit: abc1234" }) + expect(badge).toHaveAttribute("data-ref-type", "commit") + }) + + it("renders an agent link as an agent badge", () => { + render(@Codex) + const badge = screen.getByRole("img", { name: "agent: Codex" }) + expect(badge).toHaveAttribute("data-ref-type", "agent") + expect(badge.querySelector("svg")).not.toBeNull() + }) + + it("leaves a non-reference codeg uri as a normal link", () => { + render(x) + expect(screen.getByRole("button")).toBeInTheDocument() + }) + }) + + describe("file reference badges", () => { + it("renders a file link as a clickable inline file badge", () => { + render(app.ts) + // Clickable: a button wraps the badge (opens in the workspace panel). + const button = screen.getByRole("button") + expect(button).toHaveAttribute("data-resource-kind", "file") + // …whose vertical-align is centered (mirrors ReferenceBadge), not baseline. + expect(button.className).toContain("align-middle") + // `appearance-none` + `leading-none` strip the button's UA strut and the + // inherited surrounding-text line-height so it lays out like the bare + // badge; `-translate-y` then lifts the chip from the x-height midline onto + // the line's optical center (WebKit/CJK otherwise read it low). See + // MarkdownLink's file branch. + expect(button.className).toContain("appearance-none") + expect(button.className).toContain("leading-none") + expect(button.className).toContain("-translate-y-[1.5px]") + // It reads as a file badge, matching the inline `@`-file chips. + const badge = screen.getByRole("img", { name: "file: app.ts" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "file") + }) + + it("routes a file badge click through the link-safety modal hook", async () => { + mocks.onLinkCheck.mockReturnValue(false) + + render(app.ts) + fireEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(screen.getByTestId("link-modal")).toBeInTheDocument() + }) + expect(window.open).not.toHaveBeenCalled() + }) + }) + + describe("embedded attachment badges", () => { + it("renders a codeg://embedded link as an inert file badge", () => { + // Path-less pasted bytes serialize to this inert display uri; the badge + // name is the link text the composer wrote. + render( + report.pdf + ) + // It's a badge, not a clickable link (nothing to open — bytes are + // appended out of band as a resource block on send). + expect(screen.queryByRole("button")).toBeNull() + const badge = screen.getByRole("img", { name: "file: report.pdf" }) + expect(badge).toHaveAttribute("data-reference-badge") + expect(badge).toHaveAttribute("data-ref-type", "file") + }) + }) }) diff --git a/src/components/ai-elements/markdown-link.tsx b/src/components/ai-elements/markdown-link.tsx index b4bd1613b..bdd87e32d 100644 --- a/src/components/ai-elements/markdown-link.tsx +++ b/src/components/ai-elements/markdown-link.tsx @@ -1,10 +1,13 @@ "use client" -import type { ComponentProps, MouseEvent } from "react" +import type { ComponentProps, MouseEvent, ReactNode } from "react" import { useCallback, useState } from "react" import { FileText, Globe, Mail, Phone, type LucideIcon } from "lucide-react" import type { Components, LinkSafetyModalProps } from "streamdown" +import { ReferenceBadge } from "@/components/chat/composer/badges/reference-badge" +import { parseCodegReferenceUri } from "@/components/chat/composer/reference-uri" +import type { ReferenceAttrs } from "@/components/chat/composer/types" import { classifyResourceKind, type ResourceKind } from "@/lib/resource-kind" import { cn } from "@/lib/utils" import { useStreamdownLinkSafety } from "./link-safety" @@ -25,6 +28,17 @@ type MarkdownLinkProps = ComponentProps<"a"> & { node?: unknown } +/** Flatten a markdown link's children to plain text (used as the badge label). */ +function nodeText(children: ReactNode): string { + if (typeof children === "string") return children + if (Array.isArray(children)) { + return children + .map((child) => (typeof child === "string" ? child : "")) + .join("") + } + return "" +} + /** * Anchor override for markdown rendered by `` (chat messages and * reasoning blocks). It mirrors Streamdown's built-in link element — a @@ -79,6 +93,17 @@ export function MarkdownLink({ ) } + // A codeg:// reference link renders as an inline badge, mirroring the + // composer's reference chips: session / commit / agent links, plus the inert + // `codeg://embedded/…` badge a path-less pasted attachment serializes to (its + // bytes travel out of band, so it has no openable target). The same parser the + // editor uses on draft restore recovers refType/id/meta from the uri; the link + // text is the label. + if (!isIncomplete && href.toLowerCase().startsWith("codeg:")) { + const reference = parseCodegReferenceUri(href, nodeText(children)) + if (reference) return + } + const kind = isIncomplete ? null : classifyResourceKind(href) const Icon = kind ? RESOURCE_KIND_ICON[kind] : null @@ -89,6 +114,54 @@ export function MarkdownLink({ onConfirm: () => window.open(href, "_blank", "noreferrer"), } + // A file reference — a `file://` uri (rewritten to a local path by + // remark-file-uri-links before it reaches us) or a bare local path — renders + // as an inline file badge, visually matching the composer's `@`-file chips and + // the inline session/commit/agent badges above, while staying clickable: the + // same link-safety flow (`handleClick` → modal → `useOpenLinkOrFile`) opens it + // in the workspace file panel. + // + // Vertical centering happens in two steps: + // + // 1. Equalize with the bare `` span used for the + // session/commit/agent badges above. A ` + {linkSafety.renderModal ? linkSafety.renderModal(modalProps) : null} + + ) + } + return ( <>