workspace: add rich console rendering
This commit is contained in:
parent
5d07e9b9d5
commit
db5c1d64d1
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
title: 'Add rich markdown tool rendering to workspace console'
|
||||
state: 'inprogress'
|
||||
state: 'done'
|
||||
created_at: '2026-07-10T22:31:21Z'
|
||||
updated_at: '2026-07-10T22:31:41Z'
|
||||
updated_at: '2026-07-10T22:45:55Z'
|
||||
assignee: null
|
||||
queued_by: 'yoi ticket'
|
||||
queued_at: '2026-07-10T22:31:41Z'
|
||||
|
|
|
|||
|
|
@ -39,4 +39,35 @@ Ticket を `yoi ticket` が queued にしました。
|
|||
State changed to `inprogress`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: hare at: 2026-07-10T22:45:55Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Implemented rich Workspace Console rendering.
|
||||
|
||||
- Added safe Markdown-to-HTML rendering for console bodies; raw HTML is escaped.
|
||||
- Added Shiki highlighting for fenced code blocks.
|
||||
- Added TUI-inspired Console colors for message kinds, tool types, tool states, and diff rows.
|
||||
- Added structured Edit diff projection using old_string/new_string and rendered add/remove/context rows.
|
||||
- Kept Read aggregate behavior: consecutive Read calls are grouped and file content is not shown in the body.
|
||||
- Added tests for Markdown escaping/Shiki rendering and Edit diff projection.
|
||||
|
||||
Validation:
|
||||
- cd web/workspace && deno task check
|
||||
- cd web/workspace && deno task test
|
||||
- git diff --check
|
||||
- nix build .#yoi --no-link
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: "yoi ticket" at: 2026-07-10T22:45:55Z from: inprogress to: done reason: cli_state field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
State changed to `done`.
|
||||
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src src/lib/workspace-api/http.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace-api/http.test.ts src/lib/workspace-console/markdown.test.ts src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"cookie": "npm:cookie@0.6.0",
|
||||
"devalue": "npm:devalue@5.6.4",
|
||||
"set-cookie-parser": "npm:set-cookie-parser@2.7.2",
|
||||
"shiki": "npm:shiki@3.13.0",
|
||||
"svelte": "npm:svelte@5.45.6",
|
||||
"svelte-check": "npm:svelte-check@4.3.4",
|
||||
"typescript": "npm:typescript@5.9.3",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"npm:decodal-codemirror@0.1.2": "0.1.2",
|
||||
"npm:devalue@5.6.4": "5.6.4",
|
||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||
"npm:shiki@3.13.0": "3.13.0",
|
||||
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||
"npm:svelte@5.45.6": "5.45.6",
|
||||
"npm:typescript@5.9.3": "5.9.3",
|
||||
|
|
@ -346,6 +347,52 @@
|
|||
"os": ["win32"],
|
||||
"cpu": ["x64"]
|
||||
},
|
||||
"@shikijs/core@3.13.0": {
|
||||
"integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==",
|
||||
"dependencies": [
|
||||
"@shikijs/types",
|
||||
"@shikijs/vscode-textmate",
|
||||
"@types/hast",
|
||||
"hast-util-to-html"
|
||||
]
|
||||
},
|
||||
"@shikijs/engine-javascript@3.13.0": {
|
||||
"integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==",
|
||||
"dependencies": [
|
||||
"@shikijs/types",
|
||||
"@shikijs/vscode-textmate",
|
||||
"oniguruma-to-es"
|
||||
]
|
||||
},
|
||||
"@shikijs/engine-oniguruma@3.13.0": {
|
||||
"integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==",
|
||||
"dependencies": [
|
||||
"@shikijs/types",
|
||||
"@shikijs/vscode-textmate"
|
||||
]
|
||||
},
|
||||
"@shikijs/langs@3.13.0": {
|
||||
"integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==",
|
||||
"dependencies": [
|
||||
"@shikijs/types"
|
||||
]
|
||||
},
|
||||
"@shikijs/themes@3.13.0": {
|
||||
"integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==",
|
||||
"dependencies": [
|
||||
"@shikijs/types"
|
||||
]
|
||||
},
|
||||
"@shikijs/types@3.13.0": {
|
||||
"integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==",
|
||||
"dependencies": [
|
||||
"@shikijs/vscode-textmate",
|
||||
"@types/hast"
|
||||
]
|
||||
},
|
||||
"@shikijs/vscode-textmate@10.0.2": {
|
||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="
|
||||
},
|
||||
"@standard-schema/spec@1.1.0": {
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
|
||||
},
|
||||
|
|
@ -414,9 +461,27 @@
|
|||
"@types/estree@1.0.8": {
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||
},
|
||||
"@types/hast@3.0.4": {
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"dependencies": [
|
||||
"@types/unist"
|
||||
]
|
||||
},
|
||||
"@types/mdast@4.0.4": {
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"dependencies": [
|
||||
"@types/unist"
|
||||
]
|
||||
},
|
||||
"@types/unist@3.0.3": {
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
||||
},
|
||||
"@typescript-eslint/types@8.57.2": {
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
|
||||
},
|
||||
"@ungap/structured-clone@1.3.0": {
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
|
||||
},
|
||||
"acorn@8.16.0": {
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"bin": true
|
||||
|
|
@ -427,6 +492,15 @@
|
|||
"axobject-query@4.1.0": {
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
|
||||
},
|
||||
"ccount@2.0.1": {
|
||||
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
|
||||
},
|
||||
"character-entities-html4@2.1.0": {
|
||||
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="
|
||||
},
|
||||
"character-entities-legacy@3.0.0": {
|
||||
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="
|
||||
},
|
||||
"chokidar@4.0.3": {
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dependencies": [
|
||||
|
|
@ -436,6 +510,9 @@
|
|||
"clsx@2.1.1": {
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
|
||||
},
|
||||
"comma-separated-tokens@2.0.3": {
|
||||
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="
|
||||
},
|
||||
"cookie@0.6.0": {
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
|
||||
},
|
||||
|
|
@ -459,9 +536,18 @@
|
|||
"deepmerge@4.3.1": {
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
|
||||
},
|
||||
"dequal@2.0.3": {
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
|
||||
},
|
||||
"devalue@5.6.4": {
|
||||
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
|
||||
},
|
||||
"devlop@1.1.0": {
|
||||
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||
"dependencies": [
|
||||
"dequal"
|
||||
]
|
||||
},
|
||||
"esbuild@0.25.12": {
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"optionalDependencies": [
|
||||
|
|
@ -519,6 +605,31 @@
|
|||
"os": ["darwin"],
|
||||
"scripts": true
|
||||
},
|
||||
"hast-util-to-html@9.0.5": {
|
||||
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
|
||||
"dependencies": [
|
||||
"@types/hast",
|
||||
"@types/unist",
|
||||
"ccount",
|
||||
"comma-separated-tokens",
|
||||
"hast-util-whitespace",
|
||||
"html-void-elements",
|
||||
"mdast-util-to-hast",
|
||||
"property-information",
|
||||
"space-separated-tokens",
|
||||
"stringify-entities",
|
||||
"zwitch"
|
||||
]
|
||||
},
|
||||
"hast-util-whitespace@3.0.0": {
|
||||
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
|
||||
"dependencies": [
|
||||
"@types/hast"
|
||||
]
|
||||
},
|
||||
"html-void-elements@3.0.0": {
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="
|
||||
},
|
||||
"is-reference@3.0.3": {
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dependencies": [
|
||||
|
|
@ -537,6 +648,44 @@
|
|||
"@jridgewell/sourcemap-codec"
|
||||
]
|
||||
},
|
||||
"mdast-util-to-hast@13.2.1": {
|
||||
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||
"dependencies": [
|
||||
"@types/hast",
|
||||
"@types/mdast",
|
||||
"@ungap/structured-clone",
|
||||
"devlop",
|
||||
"micromark-util-sanitize-uri",
|
||||
"trim-lines",
|
||||
"unist-util-position",
|
||||
"unist-util-visit",
|
||||
"vfile"
|
||||
]
|
||||
},
|
||||
"micromark-util-character@2.1.1": {
|
||||
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
||||
"dependencies": [
|
||||
"micromark-util-symbol",
|
||||
"micromark-util-types"
|
||||
]
|
||||
},
|
||||
"micromark-util-encode@2.0.1": {
|
||||
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="
|
||||
},
|
||||
"micromark-util-sanitize-uri@2.0.1": {
|
||||
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
||||
"dependencies": [
|
||||
"micromark-util-character",
|
||||
"micromark-util-encode",
|
||||
"micromark-util-symbol"
|
||||
]
|
||||
},
|
||||
"micromark-util-symbol@2.0.1": {
|
||||
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="
|
||||
},
|
||||
"micromark-util-types@2.0.2": {
|
||||
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="
|
||||
},
|
||||
"mri@1.2.0": {
|
||||
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
|
||||
},
|
||||
|
|
@ -553,6 +702,17 @@
|
|||
"obug@2.1.1": {
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="
|
||||
},
|
||||
"oniguruma-parser@0.12.1": {
|
||||
"integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="
|
||||
},
|
||||
"oniguruma-to-es@4.3.4": {
|
||||
"integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==",
|
||||
"dependencies": [
|
||||
"oniguruma-parser",
|
||||
"regex",
|
||||
"regex-recursion"
|
||||
]
|
||||
},
|
||||
"picocolors@1.1.1": {
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
|
|
@ -567,9 +727,27 @@
|
|||
"source-map-js"
|
||||
]
|
||||
},
|
||||
"property-information@7.1.0": {
|
||||
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="
|
||||
},
|
||||
"readdirp@4.1.2": {
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
|
||||
},
|
||||
"regex-recursion@6.0.2": {
|
||||
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
|
||||
"dependencies": [
|
||||
"regex-utilities"
|
||||
]
|
||||
},
|
||||
"regex-utilities@2.3.0": {
|
||||
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="
|
||||
},
|
||||
"regex@6.1.0": {
|
||||
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
|
||||
"dependencies": [
|
||||
"regex-utilities"
|
||||
]
|
||||
},
|
||||
"rollup@4.60.0": {
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"dependencies": [
|
||||
|
|
@ -614,6 +792,19 @@
|
|||
"set-cookie-parser@2.7.2": {
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"shiki@3.13.0": {
|
||||
"integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==",
|
||||
"dependencies": [
|
||||
"@shikijs/core",
|
||||
"@shikijs/engine-javascript",
|
||||
"@shikijs/engine-oniguruma",
|
||||
"@shikijs/langs",
|
||||
"@shikijs/themes",
|
||||
"@shikijs/types",
|
||||
"@shikijs/vscode-textmate",
|
||||
"@types/hast"
|
||||
]
|
||||
},
|
||||
"sirv@3.0.2": {
|
||||
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||
"dependencies": [
|
||||
|
|
@ -625,6 +816,16 @@
|
|||
"source-map-js@1.2.1": {
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||
},
|
||||
"space-separated-tokens@2.0.2": {
|
||||
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="
|
||||
},
|
||||
"stringify-entities@4.0.4": {
|
||||
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
|
||||
"dependencies": [
|
||||
"character-entities-html4",
|
||||
"character-entities-legacy"
|
||||
]
|
||||
},
|
||||
"style-mod@4.1.3": {
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
|
||||
},
|
||||
|
|
@ -671,10 +872,60 @@
|
|||
"totalist@3.0.1": {
|
||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
|
||||
},
|
||||
"trim-lines@3.0.1": {
|
||||
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
|
||||
},
|
||||
"typescript@5.9.3": {
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"bin": true
|
||||
},
|
||||
"unist-util-is@6.0.1": {
|
||||
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
||||
"dependencies": [
|
||||
"@types/unist"
|
||||
]
|
||||
},
|
||||
"unist-util-position@5.0.0": {
|
||||
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
|
||||
"dependencies": [
|
||||
"@types/unist"
|
||||
]
|
||||
},
|
||||
"unist-util-stringify-position@4.0.0": {
|
||||
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
||||
"dependencies": [
|
||||
"@types/unist"
|
||||
]
|
||||
},
|
||||
"unist-util-visit-parents@6.0.2": {
|
||||
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
||||
"dependencies": [
|
||||
"@types/unist",
|
||||
"unist-util-is"
|
||||
]
|
||||
},
|
||||
"unist-util-visit@5.1.0": {
|
||||
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
|
||||
"dependencies": [
|
||||
"@types/unist",
|
||||
"unist-util-is",
|
||||
"unist-util-visit-parents"
|
||||
]
|
||||
},
|
||||
"vfile-message@4.0.3": {
|
||||
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
||||
"dependencies": [
|
||||
"@types/unist",
|
||||
"unist-util-stringify-position"
|
||||
]
|
||||
},
|
||||
"vfile@6.0.3": {
|
||||
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
||||
"dependencies": [
|
||||
"@types/unist",
|
||||
"vfile-message"
|
||||
]
|
||||
},
|
||||
"vite@7.2.7": {
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dependencies": [
|
||||
|
|
@ -704,6 +955,9 @@
|
|||
},
|
||||
"zimmerframe@1.1.4": {
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||
},
|
||||
"zwitch@2.0.4": {
|
||||
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
|
@ -718,6 +972,7 @@
|
|||
"npm:decodal-codemirror@0.1.2",
|
||||
"npm:devalue@5.6.4",
|
||||
"npm:set-cookie-parser@2.7.2",
|
||||
"npm:shiki@3.13.0",
|
||||
"npm:svelte-check@4.3.4",
|
||||
"npm:svelte@5.45.6",
|
||||
"npm:typescript@5.9.3",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@
|
|||
--success: oklch(48% 0.11 145);
|
||||
--warning: oklch(62% 0.12 85);
|
||||
--danger: oklch(54% 0.14 25);
|
||||
--tui-green: #76946a;
|
||||
--tui-red: #c34043;
|
||||
--tui-yellow: #c0a36e;
|
||||
--tui-blue: #7e9cd8;
|
||||
--tui-cyan: #6a9589;
|
||||
--tui-magenta: #957fb8;
|
||||
--tui-gray: #727169;
|
||||
--tui-dark-gray: #54546d;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
|
|
@ -1033,13 +1041,157 @@
|
|||
.console-log li {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0;
|
||||
padding: 0.45rem 0 0.45rem 0.75rem;
|
||||
border-left: 3px solid var(--line);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.console-log li.console-line.assistant {
|
||||
border-left-color: var(--tui-blue);
|
||||
}
|
||||
|
||||
.console-log li.console-line.user {
|
||||
border-left-color: var(--tui-green);
|
||||
}
|
||||
|
||||
.console-log li.console-line.thinking {
|
||||
border-left-color: var(--tui-magenta);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool {
|
||||
border-left-color: var(--tui-cyan);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-read {
|
||||
border-left-color: var(--tui-blue);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-write,
|
||||
.console-log li.console-line.tool-edit {
|
||||
border-left-color: var(--tui-yellow);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-bash {
|
||||
border-left-color: var(--tui-magenta);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-state-running,
|
||||
.console-log li.console-line.tool-state-streaming_args,
|
||||
.console-log li.console-line.tool-state-pending {
|
||||
border-left-color: var(--tui-yellow);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-state-done {
|
||||
border-left-color: var(--tui-green);
|
||||
}
|
||||
|
||||
.console-log li.console-line.tool-state-error,
|
||||
.console-log li.console-line.error-line {
|
||||
border-left-color: var(--tui-red);
|
||||
}
|
||||
|
||||
.rich-markdown {
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.rich-markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-markdown p,
|
||||
.rich-markdown ul,
|
||||
.rich-markdown blockquote,
|
||||
.rich-markdown pre {
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
|
||||
.rich-markdown ul {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.rich-markdown h1,
|
||||
.rich-markdown h2,
|
||||
.rich-markdown h3,
|
||||
.rich-markdown h4 {
|
||||
margin: 0.7rem 0 0.35rem;
|
||||
color: var(--text-strong);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.rich-markdown blockquote {
|
||||
border-left: 2px solid var(--tui-dark-gray);
|
||||
color: var(--text-muted);
|
||||
padding-left: 0.8rem;
|
||||
}
|
||||
|
||||
.rich-markdown :not(pre) > code {
|
||||
background: color-mix(in oklch, var(--bg-raised) 80%, var(--tui-blue));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
color: var(--tui-blue);
|
||||
padding: 0.05rem 0.28rem;
|
||||
}
|
||||
|
||||
.rich-markdown a {
|
||||
color: var(--tui-cyan);
|
||||
}
|
||||
|
||||
.rich-markdown .shiki {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.console-diff {
|
||||
background: color-mix(in oklch, var(--bg-raised) 85%, black);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
margin: 0.6rem 0 0;
|
||||
overflow-x: auto;
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: grid;
|
||||
grid-template-columns: 3.2rem 3.2rem 1.4rem minmax(0, 1fr);
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.diff-line.add {
|
||||
background: color-mix(in oklch, var(--tui-green) 18%, transparent);
|
||||
color: color-mix(in oklch, var(--tui-green) 75%, white);
|
||||
}
|
||||
|
||||
.diff-line.remove {
|
||||
background: color-mix(in oklch, var(--tui-red) 18%, transparent);
|
||||
color: color-mix(in oklch, var(--tui-red) 72%, white);
|
||||
}
|
||||
|
||||
.diff-line.context {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-gutter,
|
||||
.diff-marker {
|
||||
color: var(--tui-gray);
|
||||
padding: 0 0.5rem;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
padding-right: 0.75rem;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.console-log li.error-line {
|
||||
color: var(--danger);
|
||||
|
|
|
|||
39
web/workspace/src/lib/workspace-console/RichMarkdown.svelte
Normal file
39
web/workspace/src/lib/workspace-console/RichMarkdown.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { markdownToHtml } from "$lib/workspace-console/markdown";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { text, class: className = "" }: Props = $props();
|
||||
let html = $state("");
|
||||
let rendering = $state(false);
|
||||
|
||||
async function render(value: string): Promise<void> {
|
||||
const current = value;
|
||||
rendering = true;
|
||||
try {
|
||||
const next = await markdownToHtml(current);
|
||||
if (text === current) {
|
||||
html = next;
|
||||
}
|
||||
} finally {
|
||||
if (text === current) {
|
||||
rendering = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void render(text);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`} class:is-rendering={rendering}>
|
||||
{#if html}
|
||||
{@html html}
|
||||
{:else}
|
||||
<p>{text}</p>
|
||||
{/if}
|
||||
</div>
|
||||
36
web/workspace/src/lib/workspace-console/markdown.test.ts
Normal file
36
web/workspace/src/lib/workspace-console/markdown.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { markdownToHtml } from "./markdown.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("markdownToHtml escapes raw html and renders basic markdown", async () => {
|
||||
const html = await markdownToHtml(
|
||||
"# Title\n\nhello **world** `<x>`\n\n<script>bad()</script>",
|
||||
);
|
||||
assert(html.includes("<h1>Title</h1>"), "heading should render");
|
||||
assert(html.includes("<strong>world</strong>"), "strong should render");
|
||||
assert(
|
||||
html.includes("<script>bad()</script>"),
|
||||
"raw html should be escaped",
|
||||
);
|
||||
assert(
|
||||
!html.includes("<script>bad()</script>"),
|
||||
"raw html must not pass through",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("markdownToHtml renders fenced code through shiki", async () => {
|
||||
const html = await markdownToHtml("```ts\nconst answer: number = 42;\n```");
|
||||
assert(
|
||||
html.includes("shiki"),
|
||||
"highlighted code should include shiki markup",
|
||||
);
|
||||
assert(html.includes("answer"), "code content should be present");
|
||||
});
|
||||
225
web/workspace/src/lib/workspace-console/markdown.ts
Normal file
225
web/workspace/src/lib/workspace-console/markdown.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { codeToHtml } from "shiki";
|
||||
|
||||
const SHIKI_THEME = "kanagawa-wave";
|
||||
|
||||
export async function markdownToHtml(markdown: string): Promise<string> {
|
||||
if (!markdown.trim()) {
|
||||
return "";
|
||||
}
|
||||
const blocks = splitFencedCode(markdown);
|
||||
const html: string[] = [];
|
||||
let textBuffer: string[] = [];
|
||||
|
||||
async function flushText(): Promise<void> {
|
||||
if (textBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(renderMarkdownText(textBuffer.join("\n")));
|
||||
textBuffer = [];
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.kind === "text") {
|
||||
textBuffer.push(block.text);
|
||||
continue;
|
||||
}
|
||||
await flushText();
|
||||
html.push(await highlightCode(block.code, block.lang));
|
||||
}
|
||||
await flushText();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
type MarkdownBlock =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "code"; lang: string; code: string };
|
||||
|
||||
function splitFencedCode(markdown: string): MarkdownBlock[] {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const blocks: MarkdownBlock[] = [];
|
||||
let text: string[] = [];
|
||||
let code: string[] | null = null;
|
||||
let lang = "text";
|
||||
|
||||
for (const line of lines) {
|
||||
const fence = line.match(/^```\s*([^`]*)\s*$/);
|
||||
if (fence) {
|
||||
if (code) {
|
||||
blocks.push({ kind: "text", text: text.join("\n") });
|
||||
text = [];
|
||||
blocks.push({ kind: "code", lang, code: code.join("\n") });
|
||||
code = null;
|
||||
lang = "text";
|
||||
} else {
|
||||
if (text.length > 0) {
|
||||
blocks.push({ kind: "text", text: text.join("\n") });
|
||||
text = [];
|
||||
}
|
||||
lang = normalizeLanguage(fence[1]);
|
||||
code = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (code) {
|
||||
code.push(line);
|
||||
} else {
|
||||
text.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
text.push("```" + (lang === "text" ? "" : lang));
|
||||
text.push(...code);
|
||||
}
|
||||
if (text.length > 0) {
|
||||
blocks.push({ kind: "text", text: text.join("\n") });
|
||||
}
|
||||
|
||||
return blocks.filter((block) =>
|
||||
block.kind === "code" || block.text.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdownText(markdown: string): string {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const html: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: string[] = [];
|
||||
let blockquote: string[] = [];
|
||||
|
||||
function flushParagraph(): void {
|
||||
if (paragraph.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(`<p>${paragraph.map(renderInline).join("<br>")}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList(): void {
|
||||
if (list.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(
|
||||
`<ul>${
|
||||
list.map((item) => `<li>${renderInline(item)}</li>`).join("")
|
||||
}</ul>`,
|
||||
);
|
||||
list = [];
|
||||
}
|
||||
|
||||
function flushBlockquote(): void {
|
||||
if (blockquote.length === 0) {
|
||||
return;
|
||||
}
|
||||
html.push(
|
||||
`<blockquote>${blockquote.map(renderInline).join("<br>")}</blockquote>`,
|
||||
);
|
||||
blockquote = [];
|
||||
}
|
||||
|
||||
function flushAll(): void {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
flushBlockquote();
|
||||
}
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line.trim()) {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,4})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flushAll();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
flushBlockquote();
|
||||
list.push(listItem[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s?(.*)$/);
|
||||
if (quote) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
blockquote.push(quote[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
flushBlockquote();
|
||||
paragraph.push(line);
|
||||
}
|
||||
flushAll();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
function renderInline(value: string): string {
|
||||
let escaped = escapeHtml(value);
|
||||
escaped = escaped.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
escaped = escaped.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
escaped = escaped.replace(/\b(https?:\/\/[^\s<]+[^\s<.,;:)])/g, (url) => {
|
||||
const safe = escapeAttribute(url);
|
||||
return `<a href="${safe}" rel="noreferrer" target="_blank">${url}</a>`;
|
||||
});
|
||||
return escaped;
|
||||
}
|
||||
|
||||
async function highlightCode(code: string, lang: string): Promise<string> {
|
||||
try {
|
||||
return await codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: SHIKI_THEME,
|
||||
});
|
||||
} catch {
|
||||
return `<pre class="shiki fallback"><code>${escapeHtml(code)}</code></pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLanguage(value: string | undefined): string {
|
||||
const raw = value?.trim().split(/\s+/, 1)[0] ?? "";
|
||||
if (!raw) {
|
||||
return "text";
|
||||
}
|
||||
switch (raw) {
|
||||
case "sh":
|
||||
case "shell":
|
||||
case "zsh":
|
||||
return "bash";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "js":
|
||||
return "javascript";
|
||||
default:
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
|
|
@ -304,6 +304,49 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
|
|||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole renders Edit calls with structured diff lines", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
cursor: "60",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "edit-1",
|
||||
name: "Edit",
|
||||
arguments: JSON.stringify({
|
||||
file_path: "/tmp/a.md",
|
||||
old_string: "one\ntwo\nthree",
|
||||
new_string: "one\nTWO\nthree\nfour",
|
||||
}),
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
cursor: "61",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "edit-1",
|
||||
summary: "edited",
|
||||
output: "ok",
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Edit");
|
||||
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
|
||||
assertEquals(line.diff?.map((row) => row.kind), [
|
||||
"context",
|
||||
"remove",
|
||||
"add",
|
||||
"context",
|
||||
"add",
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -35,12 +35,20 @@ type ToolCallView = {
|
|||
isError?: boolean;
|
||||
};
|
||||
|
||||
export type ConsoleDiffLine = {
|
||||
kind: "context" | "add" | "remove";
|
||||
oldNumber?: number;
|
||||
newNumber?: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ConsoleLine = {
|
||||
id: string;
|
||||
kind: ConsoleLineKind;
|
||||
title: string;
|
||||
body: string;
|
||||
detail?: string;
|
||||
diff?: ConsoleDiffLine[];
|
||||
cursor?: string | null;
|
||||
source: "event";
|
||||
streaming?: boolean;
|
||||
|
|
@ -455,6 +463,7 @@ function refreshToolLine(item: ConsoleLine): void {
|
|||
: `Call · ${toolCall.name}`;
|
||||
item.body = renderToolCall(toolCall);
|
||||
item.detail = toolCallDetail(toolCall);
|
||||
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
|
||||
item.streaming = !["done", "error"].includes(toolCall.state);
|
||||
item.error = toolCall.state === "error";
|
||||
}
|
||||
|
|
@ -563,21 +572,95 @@ function renderWriteTool(toolCall: ToolCallView): string {
|
|||
function renderEditTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = stringField(args, "file_path") ?? "?";
|
||||
const oldString = stringField(args, "old_string");
|
||||
const newString = stringField(args, "new_string");
|
||||
const change = oldString || newString
|
||||
? compactLines([
|
||||
oldString ? `- ${firstLine(oldString)}` : undefined,
|
||||
newString ? `+ ${firstLine(newString)}` : undefined,
|
||||
])
|
||||
: undefined;
|
||||
const diff = editDiff(toolCall) ?? [];
|
||||
const removes = diff.filter((line) => line.kind === "remove").length;
|
||||
const adds = diff.filter((line) => line.kind === "add").length;
|
||||
return compactLines([
|
||||
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
||||
change,
|
||||
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
|
||||
resultText(toolCall),
|
||||
]);
|
||||
}
|
||||
|
||||
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
||||
const args = parsedArgs(toolCall);
|
||||
const oldString = stringField(args, "old_string");
|
||||
const newString = stringField(args, "new_string");
|
||||
if (oldString === undefined && newString === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return diffLines(oldString ?? "", newString ?? "");
|
||||
}
|
||||
|
||||
function diffLines(oldText: string, newText: string): ConsoleDiffLine[] {
|
||||
const oldLines = oldText.split(/\r?\n/);
|
||||
const newLines = newText.split(/\r?\n/);
|
||||
const table = lcsTable(oldLines, newLines);
|
||||
const rows: ConsoleDiffLine[] = [];
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
while (oldIndex < oldLines.length && newIndex < newLines.length) {
|
||||
if (oldLines[oldIndex] === newLines[newIndex]) {
|
||||
rows.push({
|
||||
kind: "context",
|
||||
oldNumber: oldIndex + 1,
|
||||
newNumber: newIndex + 1,
|
||||
content: oldLines[oldIndex],
|
||||
});
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
} else if (
|
||||
table[oldIndex + 1]?.[newIndex] >= table[oldIndex]?.[newIndex + 1]
|
||||
) {
|
||||
rows.push({
|
||||
kind: "remove",
|
||||
oldNumber: oldIndex + 1,
|
||||
content: oldLines[oldIndex],
|
||||
});
|
||||
oldIndex += 1;
|
||||
} else {
|
||||
rows.push({
|
||||
kind: "add",
|
||||
newNumber: newIndex + 1,
|
||||
content: newLines[newIndex],
|
||||
});
|
||||
newIndex += 1;
|
||||
}
|
||||
}
|
||||
while (oldIndex < oldLines.length) {
|
||||
rows.push({
|
||||
kind: "remove",
|
||||
oldNumber: oldIndex + 1,
|
||||
content: oldLines[oldIndex],
|
||||
});
|
||||
oldIndex += 1;
|
||||
}
|
||||
while (newIndex < newLines.length) {
|
||||
rows.push({
|
||||
kind: "add",
|
||||
newNumber: newIndex + 1,
|
||||
content: newLines[newIndex],
|
||||
});
|
||||
newIndex += 1;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
||||
const rows = Array.from(
|
||||
{ length: oldLines.length + 1 },
|
||||
() => Array(newLines.length + 1).fill(0),
|
||||
);
|
||||
for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
|
||||
for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {
|
||||
rows[oldIndex][newIndex] = oldLines[oldIndex] === newLines[newIndex]
|
||||
? rows[oldIndex + 1][newIndex + 1] + 1
|
||||
: Math.max(rows[oldIndex + 1][newIndex], rows[oldIndex][newIndex + 1]);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderSearchTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
|
||||
import {
|
||||
projectConsole,
|
||||
type ConsoleLine
|
||||
|
|
@ -243,6 +244,12 @@
|
|||
return line.error ? 'error' : line.kind;
|
||||
}
|
||||
|
||||
function toolClass(line: ConsoleLine): string {
|
||||
const name = line.toolCall?.name?.toLowerCase() ?? '';
|
||||
const state = line.toolCall?.state ?? (line.streaming ? 'streaming' : 'done');
|
||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function isNearConsoleBottom(element: HTMLElement): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
|
|
@ -326,7 +333,7 @@
|
|||
{:else}
|
||||
<ol class="console-log">
|
||||
{#each lines as item}
|
||||
<li class:assistant={lineClass(item) === 'assistant'} class:user={lineClass(item) === 'user'} class:system={lineClass(item) !== 'assistant' && lineClass(item) !== 'user'} class:error-line={item.error}>
|
||||
<li class={`console-line ${lineClass(item)} ${toolClass(item)}`} class:error-line={item.error}>
|
||||
{#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
|
||||
<div class="message-heading">
|
||||
<span>{item.title}</span>
|
||||
|
|
@ -337,7 +344,11 @@
|
|||
<small>streaming</small>
|
||||
</div>
|
||||
{/if}
|
||||
<pre>{item.body || '—'}</pre>
|
||||
<RichMarkdown text={item.body || '—'} />
|
||||
{#if item.diff}
|
||||
<pre class="console-diff" aria-label="Edit diff">{#each item.diff as diffLine}
|
||||
<span class={`diff-line ${diffLine.kind}`}><span class="diff-gutter">{diffLine.oldNumber ?? ''}</span><span class="diff-gutter">{diffLine.newNumber ?? ''}</span><span class="diff-marker">{diffLine.kind === 'add' ? '+' : diffLine.kind === 'remove' ? '-' : ' '}</span><span class="diff-content">{diffLine.content}</span></span>{/each}</pre>
|
||||
{/if}
|
||||
{#if item.detail}
|
||||
<details class="message-detail">
|
||||
<summary>detail</summary>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user