workspace: add rich console rendering

This commit is contained in:
Keisuke Hirata 2026-07-11 07:46:03 +09:00
parent 5d07e9b9d5
commit db5c1d64d1
No known key found for this signature in database
11 changed files with 891 additions and 15 deletions

View File

@ -1,8 +1,8 @@
--- ---
title: 'Add rich markdown tool rendering to workspace console' title: 'Add rich markdown tool rendering to workspace console'
state: 'inprogress' state: 'done'
created_at: '2026-07-10T22:31:21Z' created_at: '2026-07-10T22:31:21Z'
updated_at: '2026-07-10T22:31:41Z' updated_at: '2026-07-10T22:45:55Z'
assignee: null assignee: null
queued_by: 'yoi ticket' queued_by: 'yoi ticket'
queued_at: '2026-07-10T22:31:41Z' queued_at: '2026-07-10T22:31:41Z'

View File

@ -39,4 +39,35 @@ Ticket を `yoi ticket` が queued にしました。
State changed to `inprogress`. 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`.
--- ---

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@ -22,6 +22,7 @@
"cookie": "npm:cookie@0.6.0", "cookie": "npm:cookie@0.6.0",
"devalue": "npm:devalue@5.6.4", "devalue": "npm:devalue@5.6.4",
"set-cookie-parser": "npm:set-cookie-parser@2.7.2", "set-cookie-parser": "npm:set-cookie-parser@2.7.2",
"shiki": "npm:shiki@3.13.0",
"svelte": "npm:svelte@5.45.6", "svelte": "npm:svelte@5.45.6",
"svelte-check": "npm:svelte-check@4.3.4", "svelte-check": "npm:svelte-check@4.3.4",
"typescript": "npm:typescript@5.9.3", "typescript": "npm:typescript@5.9.3",

View File

@ -11,6 +11,7 @@
"npm:decodal-codemirror@0.1.2": "0.1.2", "npm:decodal-codemirror@0.1.2": "0.1.2",
"npm:devalue@5.6.4": "5.6.4", "npm:devalue@5.6.4": "5.6.4",
"npm:set-cookie-parser@2.7.2": "2.7.2", "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-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
"npm:svelte@5.45.6": "5.45.6", "npm:svelte@5.45.6": "5.45.6",
"npm:typescript@5.9.3": "5.9.3", "npm:typescript@5.9.3": "5.9.3",
@ -346,6 +347,52 @@
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"] "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": { "@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
}, },
@ -414,9 +461,27 @@
"@types/estree@1.0.8": { "@types/estree@1.0.8": {
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" "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": { "@typescript-eslint/types@8.57.2": {
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==" "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
}, },
"@ungap/structured-clone@1.3.0": {
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
},
"acorn@8.16.0": { "acorn@8.16.0": {
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"bin": true "bin": true
@ -427,6 +492,15 @@
"axobject-query@4.1.0": { "axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" "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": { "chokidar@4.0.3": {
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": [ "dependencies": [
@ -436,6 +510,9 @@
"clsx@2.1.1": { "clsx@2.1.1": {
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
}, },
"comma-separated-tokens@2.0.3": {
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="
},
"cookie@0.6.0": { "cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
}, },
@ -459,9 +536,18 @@
"deepmerge@4.3.1": { "deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
}, },
"dequal@2.0.3": {
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
},
"devalue@5.6.4": { "devalue@5.6.4": {
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==" "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="
}, },
"devlop@1.1.0": {
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"dependencies": [
"dequal"
]
},
"esbuild@0.25.12": { "esbuild@0.25.12": {
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"optionalDependencies": [ "optionalDependencies": [
@ -519,6 +605,31 @@
"os": ["darwin"], "os": ["darwin"],
"scripts": true "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": { "is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [ "dependencies": [
@ -537,6 +648,44 @@
"@jridgewell/sourcemap-codec" "@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": { "mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
}, },
@ -553,6 +702,17 @@
"obug@2.1.1": { "obug@2.1.1": {
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==" "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": { "picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
@ -567,9 +727,27 @@
"source-map-js" "source-map-js"
] ]
}, },
"property-information@7.1.0": {
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="
},
"readdirp@4.1.2": { "readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" "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": { "rollup@4.60.0": {
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
"dependencies": [ "dependencies": [
@ -614,6 +792,19 @@
"set-cookie-parser@2.7.2": { "set-cookie-parser@2.7.2": {
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "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": { "sirv@3.0.2": {
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dependencies": [ "dependencies": [
@ -625,6 +816,16 @@
"source-map-js@1.2.1": { "source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" "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": { "style-mod@4.1.3": {
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
}, },
@ -671,10 +872,60 @@
"totalist@3.0.1": { "totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
}, },
"trim-lines@3.0.1": {
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
},
"typescript@5.9.3": { "typescript@5.9.3": {
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true "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": { "vite@7.2.7": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dependencies": [ "dependencies": [
@ -704,6 +955,9 @@
}, },
"zimmerframe@1.1.4": { "zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
},
"zwitch@2.0.4": {
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
} }
}, },
"workspace": { "workspace": {
@ -718,6 +972,7 @@
"npm:decodal-codemirror@0.1.2", "npm:decodal-codemirror@0.1.2",
"npm:devalue@5.6.4", "npm:devalue@5.6.4",
"npm:set-cookie-parser@2.7.2", "npm:set-cookie-parser@2.7.2",
"npm:shiki@3.13.0",
"npm:svelte-check@4.3.4", "npm:svelte-check@4.3.4",
"npm:svelte@5.45.6", "npm:svelte@5.45.6",
"npm:typescript@5.9.3", "npm:typescript@5.9.3",

View File

@ -42,6 +42,14 @@
--success: oklch(48% 0.11 145); --success: oklch(48% 0.11 145);
--warning: oklch(62% 0.12 85); --warning: oklch(62% 0.12 85);
--danger: oklch(54% 0.14 25); --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-1: 4px;
--space-2: 8px; --space-2: 8px;
@ -1033,13 +1041,157 @@
.console-log li { .console-log li {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
padding: 0.2rem 0; padding: 0.45rem 0 0.45rem 0.75rem;
border-left: 3px solid var(--line);
background: transparent; 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 { .console-log li.error-line {
color: var(--danger); color: var(--danger);

View 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>

View 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("&lt;script&gt;bad()&lt;/script&gt;"),
"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");
});

View 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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return char;
}
});
}
function escapeAttribute(value: string): string {
return escapeHtml(value).replace(/'/g, "&#39;");
}

View File

@ -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", () => { Deno.test("projectConsole preserves in-progress assistant protocol stream", () => {
const projection = projectConsole([ const projection = projectConsole([
{ {

View File

@ -35,12 +35,20 @@ type ToolCallView = {
isError?: boolean; isError?: boolean;
}; };
export type ConsoleDiffLine = {
kind: "context" | "add" | "remove";
oldNumber?: number;
newNumber?: number;
content: string;
};
export type ConsoleLine = { export type ConsoleLine = {
id: string; id: string;
kind: ConsoleLineKind; kind: ConsoleLineKind;
title: string; title: string;
body: string; body: string;
detail?: string; detail?: string;
diff?: ConsoleDiffLine[];
cursor?: string | null; cursor?: string | null;
source: "event"; source: "event";
streaming?: boolean; streaming?: boolean;
@ -455,6 +463,7 @@ function refreshToolLine(item: ConsoleLine): void {
: `Call · ${toolCall.name}`; : `Call · ${toolCall.name}`;
item.body = renderToolCall(toolCall); item.body = renderToolCall(toolCall);
item.detail = toolCallDetail(toolCall); item.detail = toolCallDetail(toolCall);
item.diff = toolCall.name === "Edit" ? editDiff(toolCall) : undefined;
item.streaming = !["done", "error"].includes(toolCall.state); item.streaming = !["done", "error"].includes(toolCall.state);
item.error = toolCall.state === "error"; item.error = toolCall.state === "error";
} }
@ -563,21 +572,95 @@ function renderWriteTool(toolCall: ToolCallView): string {
function renderEditTool(toolCall: ToolCallView): string { function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall); const args = parsedArgs(toolCall);
const path = stringField(args, "file_path") ?? "?"; const path = stringField(args, "file_path") ?? "?";
const oldString = stringField(args, "old_string"); const diff = editDiff(toolCall) ?? [];
const newString = stringField(args, "new_string"); const removes = diff.filter((line) => line.kind === "remove").length;
const change = oldString || newString const adds = diff.filter((line) => line.kind === "add").length;
? compactLines([
oldString ? `- ${firstLine(oldString)}` : undefined,
newString ? `+ ${firstLine(newString)}` : undefined,
])
: undefined;
return compactLines([ return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`, `Edit — ${path} (${stateSuffix(toolCall.state)})`,
change, diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
resultText(toolCall), 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 { function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim(); const summary = toolCall.summary?.trim();
return compactLines([ return compactLines([

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import RichMarkdown from '$lib/workspace-console/RichMarkdown.svelte';
import { import {
projectConsole, projectConsole,
type ConsoleLine type ConsoleLine
@ -243,6 +244,12 @@
return line.error ? 'error' : line.kind; 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 { function isNearConsoleBottom(element: HTMLElement): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX; return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD_PX;
} }
@ -326,7 +333,7 @@
{:else} {:else}
<ol class="console-log"> <ol class="console-log">
{#each lines as item} {#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'} {#if lineClass(item) !== 'assistant' && lineClass(item) !== 'user'}
<div class="message-heading"> <div class="message-heading">
<span>{item.title}</span> <span>{item.title}</span>
@ -337,7 +344,11 @@
<small>streaming</small> <small>streaming</small>
</div> </div>
{/if} {/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} {#if item.detail}
<details class="message-detail"> <details class="message-detail">
<summary>detail</summary> <summary>detail</summary>