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 => (
-
- ),
- 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 }) => ,
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Plain tables
+ table: ({ 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 }) => (
+
+ ),
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Clean tables
+ table: ({ 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 }) => (
+
+ ),
+ ol: ({ children }) => {children}
,
+ li: ({ children }) => {children},
+ // Beautiful tables
+ table: ({ 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