From db5c1d64d1e6efabe69248891042a75ede9e6f41 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 11 Jul 2026 07:46:03 +0900 Subject: [PATCH] workspace: add rich console rendering --- .yoi/tickets/00001KX72G8Z6/item.md | 4 +- .yoi/tickets/00001KX72G8Z6/thread.md | 31 +++ web/workspace/deno.json | 3 +- web/workspace/deno.lock | 255 ++++++++++++++++++ web/workspace/src/app.css | 154 ++++++++++- .../lib/workspace-console/RichMarkdown.svelte | 39 +++ .../lib/workspace-console/markdown.test.ts | 36 +++ .../src/lib/workspace-console/markdown.ts | 225 ++++++++++++++++ .../src/lib/workspace-console/model.test.ts | 43 +++ .../src/lib/workspace-console/model.ts | 101 ++++++- .../workers/[workerId]/console/+page.svelte | 15 +- 11 files changed, 891 insertions(+), 15 deletions(-) create mode 100644 web/workspace/src/lib/workspace-console/RichMarkdown.svelte create mode 100644 web/workspace/src/lib/workspace-console/markdown.test.ts create mode 100644 web/workspace/src/lib/workspace-console/markdown.ts diff --git a/.yoi/tickets/00001KX72G8Z6/item.md b/.yoi/tickets/00001KX72G8Z6/item.md index f1b5623f..c28d4517 100644 --- a/.yoi/tickets/00001KX72G8Z6/item.md +++ b/.yoi/tickets/00001KX72G8Z6/item.md @@ -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' diff --git a/.yoi/tickets/00001KX72G8Z6/thread.md b/.yoi/tickets/00001KX72G8Z6/thread.md index 9d10d7b4..c4b31c4a 100644 --- a/.yoi/tickets/00001KX72G8Z6/thread.md +++ b/.yoi/tickets/00001KX72G8Z6/thread.md @@ -39,4 +39,35 @@ Ticket を `yoi ticket` が queued にしました。 State changed to `inprogress`. +--- + + + +## 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 + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 61fa710b..aa67cda9 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index c417999c..be3a6b6c 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -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", diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index 55694a9c..2f8dba73 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -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); diff --git a/web/workspace/src/lib/workspace-console/RichMarkdown.svelte b/web/workspace/src/lib/workspace-console/RichMarkdown.svelte new file mode 100644 index 00000000..51284c53 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/RichMarkdown.svelte @@ -0,0 +1,39 @@ + + +
+ {#if html} + {@html html} + {:else} +

{text}

+ {/if} +
diff --git a/web/workspace/src/lib/workspace-console/markdown.test.ts b/web/workspace/src/lib/workspace-console/markdown.test.ts new file mode 100644 index 00000000..641e490a --- /dev/null +++ b/web/workspace/src/lib/workspace-console/markdown.test.ts @@ -0,0 +1,36 @@ +import { markdownToHtml } from "./markdown.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): 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** ``\n\n", + ); + assert(html.includes("

Title

"), "heading should render"); + assert(html.includes("world"), "strong should render"); + assert( + html.includes("<script>bad()</script>"), + "raw html should be escaped", + ); + assert( + !html.includes(""), + "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"); +}); diff --git a/web/workspace/src/lib/workspace-console/markdown.ts b/web/workspace/src/lib/workspace-console/markdown.ts new file mode 100644 index 00000000..1b3f60b8 --- /dev/null +++ b/web/workspace/src/lib/workspace-console/markdown.ts @@ -0,0 +1,225 @@ +import { codeToHtml } from "shiki"; + +const SHIKI_THEME = "kanagawa-wave"; + +export async function markdownToHtml(markdown: string): Promise { + if (!markdown.trim()) { + return ""; + } + const blocks = splitFencedCode(markdown); + const html: string[] = []; + let textBuffer: string[] = []; + + async function flushText(): Promise { + 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(`

${paragraph.map(renderInline).join("
")}

`); + paragraph = []; + } + + function flushList(): void { + if (list.length === 0) { + return; + } + html.push( + `
    ${ + list.map((item) => `
  • ${renderInline(item)}
  • `).join("") + }
`, + ); + list = []; + } + + function flushBlockquote(): void { + if (blockquote.length === 0) { + return; + } + html.push( + `
${blockquote.map(renderInline).join("
")}
`, + ); + 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(`${renderInline(heading[2])}`); + 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, "$1"); + escaped = escaped.replace(/\*\*([^*]+)\*\*/g, "$1"); + escaped = escaped.replace(/\b(https?:\/\/[^\s<]+[^\s<.,;:)])/g, (url) => { + const safe = escapeAttribute(url); + return `${url}`; + }); + return escaped; +} + +async function highlightCode(code: string, lang: string): Promise { + try { + return await codeToHtml(code, { + lang: lang || "text", + theme: SHIKI_THEME, + }); + } catch { + return `
${escapeHtml(code)}
`; + } +} + +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, "'"); +} diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index f7c6549c..9c89a805 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -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([ { diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index a46828b6..afdd44a8 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -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([ diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index 84cf0ec8..37560a4e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -1,5 +1,6 @@