From aabedab72fa47c2e6ac5cc1ad372f2e7c02fde6f Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:28:07 +0800 Subject: [PATCH] feat: add syntax highlighting for code blocks - Integrate Shiki for VS Code-quality syntax highlighting - Add dark mode support with MutationObserver - Implement LRU cache (200 entries) for performance - Add streaming optimization with block-level memoization - Auto-detect and linkify URLs and file paths - Update CSP to allow WebAssembly for Shiki Closes #98 Co-Authored-By: Claude Opus 4.5 --- package.json | 4 + pnpm-lock.yaml | 245 ++++++++++++++ src/renderer/index.html | 2 +- src/renderer/src/components/ChatView.tsx | 153 ++------- .../src/components/markdown/CodeBlock.tsx | 287 +++++++++++++++++ .../src/components/markdown/Markdown.tsx | 301 ++++++++++++++++++ .../components/markdown/StreamingMarkdown.tsx | 186 +++++++++++ src/renderer/src/components/markdown/index.ts | 4 + .../src/components/markdown/linkify.ts | 195 ++++++++++++ src/renderer/src/components/ui/input.tsx | 2 +- src/renderer/src/components/ui/textarea.tsx | 2 +- 11 files changed, 1260 insertions(+), 121 deletions(-) create mode 100644 src/renderer/src/components/markdown/CodeBlock.tsx create mode 100644 src/renderer/src/components/markdown/Markdown.tsx create mode 100644 src/renderer/src/components/markdown/StreamingMarkdown.tsx create mode 100644 src/renderer/src/components/markdown/index.ts create mode 100644 src/renderer/src/components/markdown/linkify.ts diff --git a/package.json b/package.json index a54f2a087..809201ca2 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,12 @@ "electron-log": "^5.4.3", "electron-updater": "^6.7.3", "fix-path": "^5.0.0", + "linkify-it": "^5.0.0", "lucide-react": "^0.562.0", "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "shiki": "^3.21.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.3.5", @@ -61,6 +64,7 @@ "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", "@tailwindcss/vite": "^4.1.18", + "@types/linkify-it": "^5.0.0", "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 980c3faab..fca94d477 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,15 +65,24 @@ importers: fix-path: specifier: ^5.0.0 version: 5.0.0 + linkify-it: + specifier: ^5.0.0 + version: 5.0.0 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.8)(react@19.2.3) + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 + shiki: + specifier: ^3.21.0 + version: 3.21.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -99,6 +108,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + '@types/linkify-it': + specifier: ^5.0.0 + version: 5.0.0 '@types/node': specifier: ^22.19.1 version: 22.19.6 @@ -1367,6 +1379,27 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@3.21.0': + resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==} + + '@shikijs/engine-javascript@3.21.0': + resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==} + + '@shikijs/engine-oniguruma@3.21.0': + resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==} + + '@shikijs/langs@3.21.0': + resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==} + + '@shikijs/themes@3.21.0': + resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==} + + '@shikijs/types@3.21.0': + resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1513,6 +1546,9 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2613,12 +2649,30 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2639,6 +2693,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3041,6 +3098,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@15.5.2: resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} engines: {node: '>=18.12.0'} @@ -3465,6 +3525,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.4: + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3503,6 +3569,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -3681,10 +3750,22 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3835,6 +3916,9 @@ packages: resolution: {integrity: sha512-s/9q9PEtcRmDTz69+cJ3yYBAe9yGrL7e46gm2bU4pQ9N48ecPK9QrGFnLwYgb4smOHskx4PL7wCNMktW2AoD+g==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + shiki@3.21.0: + resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -4150,6 +4234,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4230,6 +4317,9 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -4317,6 +4407,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -5518,6 +5611,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@shikijs/core@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.4 + + '@shikijs/engine-oniguruma@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + + '@shikijs/themes@3.21.0': + dependencies: + '@shikijs/types': 3.21.0 + + '@shikijs/types@3.21.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} '@standard-schema/spec@1.1.0': {} @@ -5655,6 +5781,8 @@ snapshots: dependencies: '@types/node': 22.19.6 + '@types/linkify-it@5.0.0': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7114,6 +7242,51 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -7134,10 +7307,28 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -7158,6 +7349,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@7.0.2: @@ -7538,6 +7731,10 @@ snapshots: lilconfig@3.1.3: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + lint-staged@15.5.2: dependencies: chalk: 5.6.2 @@ -8190,6 +8387,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.4: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8245,6 +8450,10 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -8412,6 +8621,16 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -8421,6 +8640,12 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -8625,6 +8850,17 @@ snapshots: dependencies: shell-env: 4.0.2 + shiki@3.21.0: + dependencies: + '@shikijs/core': 3.21.0 + '@shikijs/engine-javascript': 3.21.0 + '@shikijs/engine-oniguruma': 3.21.0 + '@shikijs/langs': 3.21.0 + '@shikijs/themes': 3.21.0 + '@shikijs/types': 3.21.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -8992,6 +9228,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9082,6 +9320,11 @@ snapshots: extsprintf: 1.4.1 optional: true + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -9154,6 +9397,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} whatwg-mimetype@4.0.0: {} diff --git a/src/renderer/index.html b/src/renderer/index.html index e198e05e4..dbec352c6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index 2ed5a9776..18a7a9aec 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -3,8 +3,6 @@ * Note: Scroll behavior is managed by parent (App.tsx) for unified scroll context */ import { useState, useMemo } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' import type { StoredSessionUpdate } from '../../../shared/types' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { ChevronDown, ChevronRight, CheckCircle2, Circle, Loader2, Folder } from 'lucide-react' @@ -14,110 +12,7 @@ import { usePermissionStore } from '../stores/permissionStore' import { cn } from '@/lib/utils' import { MessageTimer, StatusIndicator, Spinner, type CurrentAction } from './ui/LoadingIndicator' import { CompletedMessageFooter } from './ui/CompletedMessageFooter' - -// Hoisted ReactMarkdown components for better performance (avoids object recreation on each render) -const TEXT_MARKDOWN_COMPONENTS = { - // Paragraphs: consistent spacing, tighter line height for readability - p: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -

{children}

- ), - // Headings: more space above (1.5x) than below (0.5x) for visual grouping - h1: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -

{children}

- ), - h2: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -

{children}

- ), - h3: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -

{children}

- ), - // Lists: consistent spacing with content - ul: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - - ), - ol: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -
    {children}
- ), - li: ({ children }: { children?: React.ReactNode }): React.JSX.Element =>
  • {children}
  • , - // Code: pre handles container, code is transparent for blocks - code: ({ - className, - children - }: { - className?: string - children?: React.ReactNode - }): React.JSX.Element => { - const isBlock = className?.includes('language-') - if (isBlock) { - // Block code inside pre - no extra styling, pre handles it - return {children} - } - // Inline code - return ( - {children} - ) - }, - pre: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -
    -      {children}
    -    
    - ), - // Links - a: ({ href, children }: { href?: string; children?: React.ReactNode }): React.JSX.Element => ( - - {children} - - ), - // Blockquote: subtle styling - blockquote: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -
    - {children} -
    - ), - hr: (): React.JSX.Element =>
    , - strong: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - em: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - // Table components for GFM table support - table: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -
    - {children}
    -
    - ), - thead: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - tbody: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - tr: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - th: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ), - td: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ) -} - -// Simpler markdown components for thought blocks -const THOUGHT_MARKDOWN_COMPONENTS = { - p: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( -

    {children}

    - ), - code: ({ children }: { children?: React.ReactNode }): React.JSX.Element => ( - {children} - ) -} +import { Markdown, StreamingMarkdown } from './markdown' interface ChatViewProps { updates: StoredSessionUpdate[] @@ -669,7 +564,7 @@ function MessageBubble({ return (
    -
    +
    {/* Render images first */} {imageBlocks.length > 0 && (
    @@ -706,14 +601,20 @@ function MessageBubble({ } // Render a single content block -function renderContentBlock(block: ContentBlock, idx: number): React.JSX.Element | null { +function renderContentBlock( + block: ContentBlock, + idx: number, + isStreaming: boolean = false +): React.JSX.Element | null { switch (block.type) { case 'thought': return case 'tool_call': return case 'text': - return + return ( + + ) case 'image': return ( b.content) .join('\n\n') + // Streaming state: only the last text block is streaming when message is not complete + const isStreaming = !isComplete + // Not collapsible - render all blocks normally if (!shouldCollapse) { return (
    - {blocks.map((block, idx) => renderContentBlock(block, idx))} + {blocks.map((block, idx) => { + // Only the last text block gets streaming optimization + const isLastTextBlock = + block.type === 'text' && blocks.slice(idx + 1).every((b) => b.type !== 'text') + return renderContentBlock(block, idx, isStreaming && isLastTextBlock) + })} {/* Processing: show spinner + time + label */} {isProcessing && ( - - {content} - +
    + {isStreaming ? ( + + ) : ( + {content} + )}
    ) } @@ -927,21 +844,21 @@ function ThoughtBlockView({ text }: { text: string }): React.JSX.Element | null
    - {text} + {text}
    {/* Preview when collapsed */} {!isExpanded && isLong && (
    - {text} + {text}
    )} {/* Show full content when not long */} {!isLong && (
    - {text} + {text}
    )}
    diff --git a/src/renderer/src/components/markdown/CodeBlock.tsx b/src/renderer/src/components/markdown/CodeBlock.tsx new file mode 100644 index 000000000..ced571483 --- /dev/null +++ b/src/renderer/src/components/markdown/CodeBlock.tsx @@ -0,0 +1,287 @@ +import * as React from 'react' +import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki' +import { cn } from '@/lib/utils' + +/** + * Hook to detect dark mode changes via MutationObserver + * Returns true if 'dark' class is present on document.documentElement + */ +function useIsDarkMode(): boolean { + const [isDark, setIsDark] = React.useState(() => + document.documentElement.classList.contains('dark') + ) + + React.useEffect(() => { + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.attributeName === 'class') { + setIsDark(document.documentElement.classList.contains('dark')) + } + } + }) + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }) + + return () => observer.disconnect() + }, []) + + return isDark +} + +export interface CodeBlockProps { + code: string + language?: string + className?: string + /** + * Render mode affects code block styling: + * - 'terminal': Minimal, keeps control chars visible + * - 'minimal': Clean code, basic styling + * - 'full': Rich styling with background, copy button, etc. + */ + mode?: 'terminal' | 'minimal' | 'full' + /** + * Force a specific theme. If not provided, detects from document.documentElement.classList + */ + forcedTheme?: 'light' | 'dark' +} + +// Map common aliases to Shiki language names +const LANGUAGE_ALIASES: Record = { + js: 'javascript', + ts: 'typescript', + py: 'python', + sh: 'bash', + zsh: 'bash', + yml: 'yaml', + rb: 'ruby', + rs: 'rust', + kt: 'kotlin', + 'objective-c': 'objc', + objc: 'objc' +} + +// Simple LRU cache for highlighted code +const highlightCache = new Map() +const CACHE_MAX_SIZE = 200 + +function getCacheKey(code: string, lang: string, theme: string): string { + return `${theme}:${lang}:${code}` +} + +function isValidLanguage(lang: string): lang is BundledLanguage { + const normalized = LANGUAGE_ALIASES[lang] || lang + return normalized in bundledLanguages +} + +/** + * CodeBlock - Syntax highlighted code block using Shiki + * + * Uses VS Code's syntax highlighting engine for accurate highlighting. + * Lazy-loads highlighting and caches results for performance. + */ +export function CodeBlock({ + code, + language = 'text', + className, + mode = 'full', + forcedTheme +}: CodeBlockProps): React.JSX.Element { + const [highlighted, setHighlighted] = React.useState(null) + const [isLoading, setIsLoading] = React.useState(true) + const [copied, setCopied] = React.useState(false) + + // Subscribe to dark mode changes via MutationObserver + const isDarkMode = useIsDarkMode() + + // Resolve language alias - keep as string to allow 'text' fallback + const langLower = language.toLowerCase() + const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower + + React.useEffect(() => { + let cancelled = false + + async function highlight(): Promise { + // Theme priority: + // 1. forcedTheme prop - explicit override + // 2. isDarkMode from hook - reactive to theme changes + let theme: string + if (forcedTheme) { + theme = forcedTheme === 'dark' ? 'github-dark' : 'github-light' + } else { + theme = isDarkMode ? 'github-dark' : 'github-light' + } + const cacheKey = getCacheKey(code, resolvedLang, theme) + + const cached = highlightCache.get(cacheKey) + if (cached) { + if (!cancelled) { + setHighlighted(cached) + setIsLoading(false) + } + return + } + + try { + // Use valid language or fallback to plaintext + const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text' + + const html = await codeToHtml(code, { + lang, + theme + }) + + // Cache the result + if (highlightCache.size >= CACHE_MAX_SIZE) { + const firstKey = highlightCache.keys().next().value + if (firstKey) highlightCache.delete(firstKey) + } + highlightCache.set(cacheKey, html) + + if (!cancelled) { + setHighlighted(html) + setIsLoading(false) + } + } catch (error) { + // Fallback to plain text on error + console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error) + if (!cancelled) { + setHighlighted(null) + setIsLoading(false) + } + } + } + + highlight() + + return () => { + cancelled = true + } + }, [code, resolvedLang, forcedTheme, isDarkMode]) + + const handleCopy = React.useCallback(async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy code:', err) + } + }, [code]) + + // Terminal mode: raw monospace with minimal styling + if (mode === 'terminal') { + return ( +
    +        {code}
    +      
    + ) + } + + // Minimal mode: just syntax highlighting, no chrome + if (mode === 'minimal') { + if (isLoading || !highlighted) { + return ( +
    +          {code}
    +        
    + ) + } + + return ( +
    + ) + } + + // Full mode: rich styling with header and copy button + return ( +
    + {/* Language label + copy button */} +
    + + {resolvedLang !== 'text' ? resolvedLang : 'plain text'} + + +
    + + {/* Code content */} +
    + {isLoading || !highlighted ? ( +
    +            {code}
    +          
    + ) : ( +
    + )} +
    +
    + ) +} + +/** + * InlineCode - Styled inline code span + * Features: subtle background (3%), subtle border (5%), 75% opacity text + */ +export function InlineCode({ + children, + className +}: { + children: React.ReactNode + className?: string +}): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/markdown/Markdown.tsx b/src/renderer/src/components/markdown/Markdown.tsx new file mode 100644 index 000000000..114b71e41 --- /dev/null +++ b/src/renderer/src/components/markdown/Markdown.tsx @@ -0,0 +1,301 @@ +import * as React from 'react' +import ReactMarkdown, { type Components } from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' +import { CodeBlock, InlineCode } from './CodeBlock' +import { preprocessLinks } from './linkify' + +/** + * Render modes for markdown content: + * + * - 'terminal': Raw output with minimal formatting, control chars visible + * Best for: Debug output, raw logs, when you want to see exactly what's there + * + * - 'minimal': Clean rendering with syntax highlighting but no extra chrome + * Best for: Chat messages, inline content, when you want readability without clutter + * + * - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography + * Best for: Documentation, long-form content, when presentation matters + */ +export type RenderMode = 'terminal' | 'minimal' | 'full' + +export interface MarkdownProps { + children: string + /** + * Render mode controlling formatting level + * @default 'minimal' + */ + mode?: RenderMode + className?: string + /** + * Message ID for memoization (optional) + * When provided, memoizes parsed blocks to avoid re-parsing during streaming + */ + id?: string + /** + * Callback when a URL is clicked + */ + onUrlClick?: (url: string) => void + /** + * Callback when a file path is clicked + */ + onFileClick?: (path: string) => void +} + +// File path detection regex - matches paths starting with /, ~/, or ./ +const FILE_PATH_REGEX = + /^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i + +/** + * Create custom components based on render mode + */ +function createComponents( + mode: RenderMode, + onUrlClick?: (url: string) => void, + onFileClick?: (path: string) => void +): Partial { + const baseComponents: Partial = { + // Links: Make clickable with callbacks + a: ({ href, children }) => { + const handleClick = (e: React.MouseEvent): void => { + e.preventDefault() + if (href) { + // Check if it's a file path + if (FILE_PATH_REGEX.test(href) && onFileClick) { + onFileClick(href) + } else if (onUrlClick) { + onUrlClick(href) + } else { + // Default: open in new window + window.open(href, '_blank', 'noopener,noreferrer') + } + } + } + + return ( + + {children} + + ) + } + } + + // Terminal mode: minimal formatting + if (mode === 'terminal') { + return { + ...baseComponents, + // No special code handling - just monospace + code: ({ children }) => {children}, + pre: ({ children }) =>
    {children}
    , + // Minimal paragraph spacing + p: ({ children }) =>

    {children}

    , + // Simple lists + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + // Plain tables + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children} + } + } + + // Minimal mode: clean with syntax highlighting + if (mode === 'minimal') { + return { + ...baseComponents, + // Inline code + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || '') + const isBlock = + 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line + + // Block code - use CodeBlock with full mode + if (match || isBlock) { + const code = String(children).replace(/\n$/, '') + return + } + + // Inline code + return {children} + }, + pre: ({ children }) => <>{children}, + // Comfortable paragraph spacing + p: ({ children }) =>

    {children}

    , + // Styled lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + // Clean tables + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + {children} + ), + td: ({ children }) => {children}, + // Headings - H1/H2 same size, differentiated by weight + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + // Blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Horizontal rules + hr: () =>
    , + // Strong/emphasis + strong: ({ children }) => {children}, + em: ({ children }) => {children} + } + } + + // Full mode: rich styling + return { + ...baseComponents, + // Full code blocks with copy button + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || '') + const isBlock = + 'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line + + if (match || isBlock) { + const code = String(children).replace(/\n$/, '') + return + } + + return {children} + }, + pre: ({ children }) => <>{children}, + // Rich paragraph spacing + p: ({ children }) =>

    {children}

    , + // Styled lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + // Beautiful tables + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, + tr: ({ children }) => {children}, + // Rich headings + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) =>

    {children}

    , + h4: ({ children }) =>

    {children}

    , + // Styled blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Task lists (GFM) + input: ({ type, checked }) => { + if (type === 'checkbox') { + return ( + + ) + } + return + }, + // Horizontal rules + hr: () =>
    , + // Strong/emphasis + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + del: ({ children }) => {children} + } +} + +/** + * Markdown - Customizable markdown renderer with multiple render modes + * + * Features: + * - Three render modes: terminal, minimal, full + * - Syntax highlighting via Shiki + * - GFM support (tables, task lists, strikethrough) + * - Clickable links and file paths + * - Memoization for streaming performance + */ +export function Markdown({ + children, + mode = 'minimal', + className, + onUrlClick, + onFileClick +}: MarkdownProps): React.JSX.Element { + const components = React.useMemo( + () => createComponents(mode, onUrlClick, onFileClick), + [mode, onUrlClick, onFileClick] + ) + + // Preprocess to convert raw URLs and file paths to markdown links + const processedContent = React.useMemo(() => preprocessLinks(children), [children]) + + return ( +
    + + {processedContent} + +
    + ) +} + +/** + * MemoizedMarkdown - Optimized for streaming scenarios + * + * Splits content into blocks and memoizes each block separately, + * so only new/changed blocks re-render during streaming. + */ +export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => { + // If id is provided, use it for memoization + if (prevProps.id && nextProps.id) { + return ( + prevProps.id === nextProps.id && + prevProps.children === nextProps.children && + prevProps.mode === nextProps.mode + ) + } + // Otherwise compare content and mode + return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode +}) +MemoizedMarkdown.displayName = 'MemoizedMarkdown' diff --git a/src/renderer/src/components/markdown/StreamingMarkdown.tsx b/src/renderer/src/components/markdown/StreamingMarkdown.tsx new file mode 100644 index 000000000..6d68435fd --- /dev/null +++ b/src/renderer/src/components/markdown/StreamingMarkdown.tsx @@ -0,0 +1,186 @@ +import * as React from 'react' +import { Markdown, type RenderMode } from './Markdown' + +export interface StreamingMarkdownProps { + content: string + isStreaming: boolean + mode?: RenderMode + onUrlClick?: (url: string) => void + onFileClick?: (path: string) => void +} + +interface Block { + content: string + isCodeBlock: boolean +} + +/** + * Simple hash function for cache keys + * Uses djb2 algorithm - fast and produces good distribution + */ +function simpleHash(str: string): string { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) ^ str.charCodeAt(i) + } + return (hash >>> 0).toString(36) +} + +/** + * Split content into blocks (paragraphs and code blocks) + * + * Block boundaries: + * - Double newlines (paragraph separators) + * - Code fences (```) + * + * This is intentionally simple - just string scanning, no regex per line. + */ +function splitIntoBlocks(content: string): Block[] { + const blocks: Block[] = [] + const lines = content.split('\n') + let currentBlock = '' + let inCodeBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check for code fence (``` at start of line, optionally followed by language) + if (line.startsWith('```')) { + if (!inCodeBlock) { + // Starting a code block - flush current paragraph first + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + inCodeBlock = true + currentBlock = line + '\n' + } else { + // Ending a code block + currentBlock += line + blocks.push({ content: currentBlock, isCodeBlock: true }) + currentBlock = '' + inCodeBlock = false + } + } else if (inCodeBlock) { + // Inside code block - append line + currentBlock += line + '\n' + } else if (line === '') { + // Empty line outside code block = paragraph boundary + if (currentBlock.trim()) { + blocks.push({ content: currentBlock.trim(), isCodeBlock: false }) + currentBlock = '' + } + } else { + // Regular text line + if (currentBlock) { + currentBlock += '\n' + line + } else { + currentBlock = line + } + } + } + + // Flush remaining content + if (currentBlock) { + blocks.push({ + content: inCodeBlock ? currentBlock : currentBlock.trim(), + isCodeBlock: inCodeBlock // Unclosed code block = still streaming + }) + } + + return blocks +} + +/** + * Memoized block component + * + * Only re-renders if content or mode changes. + * The key is assigned by the parent based on content hash, + * so identical content won't even attempt to render. + */ +const MemoizedBlock = React.memo( + function Block({ + content, + mode, + onUrlClick, + onFileClick + }: { + content: string + mode: RenderMode + onUrlClick?: (url: string) => void + onFileClick?: (path: string) => void + }) { + return ( + + {content} + + ) + }, + (prev, next) => { + // Only re-render if content actually changed + return prev.content === next.content && prev.mode === next.mode + } +) +MemoizedBlock.displayName = 'MemoizedBlock' + +/** + * StreamingMarkdown - Optimized markdown renderer for streaming content + * + * Splits content into blocks (paragraphs, code blocks) and memoizes each block + * independently. Only the last (active) block re-renders during streaming. + * + * Key insight: Completed blocks get a content-hash as their React key. + * Same content = same key = React skips re-render entirely. + * + * @example + * Content: "Hello\n\n```js\ncode\n```\n\nMore..." + * + * Block 1: "Hello" -> key="block-abc123" -> memoized + * Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized + * Block 3: "More..." -> key="active-2" -> re-renders + */ +export function StreamingMarkdown({ + content, + isStreaming, + mode = 'minimal', + onUrlClick, + onFileClick +}: StreamingMarkdownProps): React.JSX.Element { + // Split into blocks - memoized to avoid recomputation + // Must be called unconditionally to satisfy Rules of Hooks + const blocks = React.useMemo( + () => (isStreaming ? splitIntoBlocks(content) : []), + [content, isStreaming] + ) + + // Not streaming - use simple Markdown (no block splitting needed) + if (!isStreaming) { + return ( + + {content} + + ) + } + + return ( + <> + {blocks.map((block, i) => { + const isLastBlock = i === blocks.length - 1 + + // Complete blocks use content hash as key -> stable identity -> memoized + // Last block uses "active" prefix -> always re-renders on content change + const key = isLastBlock ? `active-${i}` : `block-${simpleHash(block.content)}` + + return ( + + ) + })} + + ) +} diff --git a/src/renderer/src/components/markdown/index.ts b/src/renderer/src/components/markdown/index.ts new file mode 100644 index 000000000..a2a89c507 --- /dev/null +++ b/src/renderer/src/components/markdown/index.ts @@ -0,0 +1,4 @@ +export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from './Markdown' +export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock' +export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown' +export { preprocessLinks, detectLinks, hasLinks } from './linkify' diff --git a/src/renderer/src/components/markdown/linkify.ts b/src/renderer/src/components/markdown/linkify.ts new file mode 100644 index 000000000..ed06a9b72 --- /dev/null +++ b/src/renderer/src/components/markdown/linkify.ts @@ -0,0 +1,195 @@ +import LinkifyIt from 'linkify-it' + +/** + * Linkify - URL and file path detection for markdown preprocessing + * + * Uses linkify-it (12M downloads/week) for battle-tested URL detection, + * plus custom regex for local file paths. + */ + +// Initialize linkify-it with default settings (fuzzy URLs, emails enabled) +const linkify = new LinkifyIt() + +// File path regex - detects /path, ~/path, ./path with common extensions +// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension +const FILE_PATH_REGEX = + /(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi + +interface DetectedLink { + type: 'url' | 'email' | 'file' + text: string + url: string + start: number + end: number +} + +interface CodeRange { + start: number + end: number +} + +/** + * Find all code block and inline code ranges in text + * These ranges should be excluded from link detection + */ +function findCodeRanges(text: string): CodeRange[] { + const ranges: CodeRange[] = [] + + // Find fenced code blocks (```...```) + const fencedRegex = /```[\s\S]*?```/g + let match + while ((match = fencedRegex.exec(text)) !== null) { + ranges.push({ start: match.index, end: match.index + match[0].length }) + } + + // Find inline code (`...`) + // But skip escaped backticks and code inside fenced blocks + const inlineRegex = /(? pos >= r.start && pos < r.end) + if (!insideFenced) { + ranges.push({ start: pos, end: pos + match[0].length }) + } + } + + return ranges +} + +/** + * Check if a position is inside any code range + */ +function isInsideCode(pos: number, ranges: CodeRange[]): boolean { + return ranges.some((r) => pos >= r.start && pos < r.end) +} + +/** + * Check if a link at given position is already a markdown link + * Looks for patterns like [text](url) or [text][ref] + */ +function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean { + // Check if preceded by ]( which indicates we're inside a markdown link href + // Pattern: [text](URL) - we're checking if URL is our link + const before = text.slice(Math.max(0, linkStart - 2), linkStart) + if (before.endsWith('](')) return true + + // Check if preceded by ][ for reference links + if (before.endsWith('][')) return true + + // Check if the link text is wrapped in [] + // Pattern: [URL](href) - URL is being used as link text + const charBefore = text[linkStart - 1] + const charAfter = text[linkEnd] + if (charBefore === '[' && charAfter === ']') return true + + return false +} + +/** + * Check if ranges overlap + */ +function rangesOverlap( + a: { start: number; end: number }, + b: { start: number; end: number } +): boolean { + return a.start < b.end && b.start < a.end +} + +/** + * Detect all links (URLs, emails, file paths) in text + */ +export function detectLinks(text: string): DetectedLink[] { + const links: DetectedLink[] = [] + + // 1. Detect URLs and emails with linkify-it + const urlMatches = linkify.match(text) || [] + for (const match of urlMatches) { + links.push({ + type: match.schema === 'mailto:' ? 'email' : 'url', + text: match.text, + url: match.url, + start: match.index, + end: match.lastIndex + }) + } + + // 2. Detect file paths with custom regex + // Reset regex state + FILE_PATH_REGEX.lastIndex = 0 + let fileMatch + while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) { + const path = fileMatch[1] + if (!path) continue // Skip if no capture group + + // Calculate actual start position (after any leading whitespace/punctuation) + const fullMatch = fileMatch[0] + const pathOffset = fullMatch.indexOf(path) + const start = fileMatch.index + pathOffset + + // Check for overlaps with URL matches (URLs take precedence) + const pathRange = { start, end: start + path.length } + const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link)) + if (overlapsUrl) continue + + links.push({ + type: 'file', + text: path, + url: path, // File paths are passed as-is to onFileClick handler + start, + end: start + path.length + }) + } + + // Sort by position + return links.sort((a, b) => a.start - b.start) +} + +/** + * Preprocess text to convert raw URLs and file paths into markdown links + * Skips code blocks and already-linked content + */ +export function preprocessLinks(text: string): string { + // Quick check - if no potential links, return early + if (!linkify.pretest(text) && !/[~/.]\//.test(text)) { + return text + } + + const codeRanges = findCodeRanges(text) + const links = detectLinks(text) + + if (links.length === 0) return text + + // Build result, converting raw links to markdown links + let result = '' + let lastIndex = 0 + + for (const link of links) { + // Skip if inside code block + if (isInsideCode(link.start, codeRanges)) continue + + // Skip if already a markdown link + if (isAlreadyLinked(text, link.start, link.end)) continue + + // Add text before this link + result += text.slice(lastIndex, link.start) + + // Convert to markdown link + result += `[${link.text}](${link.url})` + + lastIndex = link.end + } + + // Add remaining text + result += text.slice(lastIndex) + + return result +} + +/** + * Test if text contains any detectable links + * Useful for optimization - skip preprocessing if no links present + */ +export function hasLinks(text: string): boolean { + return linkify.pretest(text) || /[~/.]\/[\w]/.test(text) +} diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx index 25c9d1101..678b14074 100644 --- a/src/renderer/src/components/ui/input.tsx +++ b/src/renderer/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>): Re type={type} data-slot="input" className={cn( - 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', className diff --git a/src/renderer/src/components/ui/textarea.tsx b/src/renderer/src/components/ui/textarea.tsx index 66c0d9f29..2b8bc0480 100644 --- a/src/renderer/src/components/ui/textarea.tsx +++ b/src/renderer/src/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<'textarea'>): Re