Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"dependencies": {
"@opencode-ai/sdk": "^1.2.10",
"dompurify": "^3.3.1",
"highlight.js": "^11.11.1",
"react-icons": "^5.5.0"
}
}
5 changes: 5 additions & 0 deletions src/chat-view-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export type WebviewToExtMessage =
| { type: "undoSession"; sessionId: string; messageId: string }
| { type: "redoSession"; sessionId: string }
| { type: "openDiffEditor"; filePath: string; before: string; after: string }
| { type: "copyToClipboard"; text: string }
| { type: "ready" };

export class ChatViewProvider implements vscode.WebviewViewProvider {
Expand Down Expand Up @@ -397,6 +398,10 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
this.postMessage({ type: "activeSession", session });
break;
}
case "copyToClipboard": {
await vscode.env.clipboard.writeText(message.text);
break;
}
case "undoSession": {
const session = await this.connection.revertSession(message.sessionId, message.messageId);
this.activeSession = session;
Expand Down
8 changes: 7 additions & 1 deletion webview/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@ vi.mock("../vscode-api", () => ({
}));

// --- marked モック ---
// TextPartView は marked.parse を使うが、テストでは markdown レンダリングの検証は不要。
// TextPartView は Marked インスタンスを使うが、テストでは markdown レンダリングの検証は不要。
// プレーンテキストをそのまま <p> タグで返す。

vi.mock("marked", () => ({
Marked: class {
parse(text: string) {
return `<p>${text}</p>`;
}
},
marked: {
parse: (text: string) => `<p>${text}</p>`,
setOptions: vi.fn(),
use: vi.fn(),
},
}));

Expand Down
56 changes: 56 additions & 0 deletions webview/__tests__/utils/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { preprocessNestedCodeBlocks } from "../../utils/markdown";

describe("preprocessNestedCodeBlocks", () => {
it("内部にフェンスが無いコードブロックはそのまま返す", () => {
const input = "```ts\nconst x = 1;\n```";
expect(preprocessNestedCodeBlocks(input)).toBe(input);
});

it("コードブロック外のテキストはそのまま返す", () => {
const input = "Hello world\n\nSome text";
expect(preprocessNestedCodeBlocks(input)).toBe(input);
});

it("内部に同じ長さのフェンスがある場合、外側のフェンスを拡張する", () => {
const input = "```markdown\nHere is code:\n```ts\nconst x = 1;\n```\n```";
const result = preprocessNestedCodeBlocks(input);
// 外側のフェンスが ```` に拡張されるはず
expect(result).toBe("````markdown\nHere is code:\n```ts\nconst x = 1;\n```\n````");
});

it("内部に4文字のフェンスがある場合、外側を5文字に拡張する", () => {
const input = "````md\nSome:\n````ts\ncode\n````\n````";
const result = preprocessNestedCodeBlocks(input);
expect(result).toBe("`````md\nSome:\n````ts\ncode\n````\n`````");
});

it("チルダフェンスも正しく処理する", () => {
const input = "~~~md\n~~~ts\ncode\n~~~\n~~~";
const result = preprocessNestedCodeBlocks(input);
expect(result).toBe("~~~~md\n~~~ts\ncode\n~~~\n~~~~");
});

it("閉じフェンスが無い場合はそのまま返す", () => {
const input = "```ts\nconst x = 1;\nno closing";
expect(preprocessNestedCodeBlocks(input)).toBe(input);
});

it("複数のコードブロックを正しく処理する", () => {
const input = "text\n```md\n```ts\nx\n```\n```\nmore text\n```js\nconsole.log();\n```";
const result = preprocessNestedCodeBlocks(input);
expect(result).toBe("text\n````md\n```ts\nx\n```\n````\nmore text\n```js\nconsole.log();\n```");
});

it("深くネストされたフェンスも正しく処理する", () => {
const input = "```md\nOuter:\n```inner\n````deeper\ncode\n````\n```\n```";
const result = preprocessNestedCodeBlocks(input);
// 内部に ```` (4文字) があるので外側は ````` (5文字) になる
expect(result).toBe("`````md\nOuter:\n```inner\n````deeper\ncode\n````\n```\n`````");
});

it("フェンス内にインラインバッククォートがあっても影響しない", () => {
const input = "```ts\nconst s = `hello`;\n```";
expect(preprocessNestedCodeBlocks(input)).toBe(input);
});
});
72 changes: 63 additions & 9 deletions webview/components/molecules/TextPartView/TextPartView.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,77 @@
import type { TextPart } from "@opencode-ai/sdk";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { useMemo } from "react";
import hljs from "highlight.js/lib/common";
import { Marked, type Renderer, type Tokens } from "marked";
import { useCallback, useMemo } from "react";
import { preprocessNestedCodeBlocks } from "../../../utils/markdown";
import { postMessage } from "../../../vscode-api";

// marked のデフォルト設定
marked.setOptions({
breaks: true,
});
// --- SVG アイコン (VSC アイコン相当) ---
const COPY_ICON = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4l1-1h5.414L14 6.586V14l-1 1H5l-1-1V4zm9 3l-3-3H5v10h8V7z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3 1L2 2v10l1 1V2h6.414l-1-1H3z"/></svg>`;
const CHECK_ICON = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.431 3.323l-8.47 10-.79-.036-3.35-4.77.818-.574 2.978 4.24 8.051-9.506.763.646z"/></svg>`;

/**
* コードブロック用カスタムレンダラー。
* - highlight.js によるシンタックスハイライトを適用する
* - ヘッダーにコピーボタン(plain HTML)を直接出力する
*/
const codeRenderer: Partial<Renderer> = {
code({ text, lang }: Tokens.Code): string {
let highlighted: string;
if (lang && hljs.getLanguage(lang)) {
highlighted = hljs.highlight(text, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(text).value;
}
const langLabel = lang ? `<span class="code-block-lang">${lang}</span>` : "";
const copyBtn = `<button class="code-block-copy" type="button" aria-label="Copy code">${COPY_ICON}</button>`;
return `<div class="code-block-wrapper"><div class="code-block-header">${langLabel}${copyBtn}</div><pre><code class="hljs${lang ? ` language-${lang}` : ""}">${highlighted}</code></pre></div>`;
},
};

// DOMPurify で SVG 要素を許可する設定
const PURIFY_CONFIG: DOMPurify.Config = {
ADD_TAGS: ["svg", "path"],
ADD_ATTR: ["viewBox", "fill", "fill-rule", "clip-rule", "d", "xmlns"],
};

// marked インスタンス(グローバル状態を汚染しない)
const markdownParser = new Marked({ breaks: true }, { renderer: codeRenderer });

type Props = {
part: TextPart;
};

export function TextPartView({ part }: Props) {
const html = useMemo(() => {
const raw = marked.parse(part.text, { async: false }) as string;
return DOMPurify.sanitize(raw);
const preprocessed = preprocessNestedCodeBlocks(part.text);
const raw = markdownParser.parse(preprocessed, { async: false }) as string;
return DOMPurify.sanitize(raw, PURIFY_CONFIG);
}, [part.text]);

// イベント委譲: コンテナ要素に1つのクリックハンドラーを付けて
// .code-block-copy ボタンのクリックを検出する
const handleClick = useCallback((e: React.MouseEvent<HTMLSpanElement>) => {
const target = e.target as HTMLElement;
const btn = target.closest<HTMLButtonElement>(".code-block-copy");
if (!btn) return;

const wrapper = btn.closest(".code-block-wrapper");
const codeEl = wrapper?.querySelector<HTMLElement>("pre code");
if (!codeEl) return;

const code = codeEl.textContent ?? "";
postMessage({ type: "copyToClipboard", text: code });

btn.innerHTML = CHECK_ICON;
btn.classList.add("copied");
setTimeout(() => {
btn.innerHTML = COPY_ICON;
btn.classList.remove("copied");
}, 1500);
}, []);

// biome-ignore lint/security/noDangerouslySetInnerHtml: DOMPurify でサニタイズ済みの HTML を描画する
return <span className="markdown" dangerouslySetInnerHTML={{ __html: html }} />;
// biome-ignore lint/a11y/useKeyWithClickEvents: コピーボタンのイベント委譲
return <span className="markdown" onClick={handleClick} dangerouslySetInnerHTML={{ __html: html }} />;
}
113 changes: 106 additions & 7 deletions webview/components/organisms/MessageItem/MessageItem.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
.assistant .content {
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}

Expand Down Expand Up @@ -110,9 +109,9 @@
.content :global(.markdown) pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 8px 12px;
border-radius: 4px;
border-radius: 0 0 4px 4px;
overflow-x: auto;
margin: 8px 0;
margin: 0;
}

.content :global(.markdown) pre code {
Expand All @@ -123,25 +122,125 @@
.content :global(.markdown) ul,
.content :global(.markdown) ol {
padding-left: 1.5em;
margin: 2px 0;
margin: 4px 0;
}

.content :global(.markdown) li {
margin: 0;
padding: 0;
line-height: 1.4;
line-height: 1.5;
}

.content :global(.markdown) li + li {
margin-top: 1px;
margin-top: 2px;
}

.content :global(.markdown) li > p {
margin: 0;
}

.content :global(.markdown) li > ul,
.content :global(.markdown) li > ol {
margin: 2px 0 0 0;
}

.content :global(.markdown) p {
margin: 4px 0;
margin: 8px 0;
}

/* --- Headings --- */

.content :global(.markdown) h1,
.content :global(.markdown) h2,
.content :global(.markdown) h3,
.content :global(.markdown) h4,
.content :global(.markdown) h5,
.content :global(.markdown) h6 {
margin: 16px 0 8px 0;
line-height: 1.3;
font-weight: 600;
}

.content :global(.markdown) h1 {
font-size: 1.5em;
}

.content :global(.markdown) h2 {
font-size: 1.3em;
}

.content :global(.markdown) h3 {
font-size: 1.15em;
}

.content :global(.markdown) h4 {
font-size: 1.05em;
}

.content :global(.markdown) h5 {
font-size: 0.95em;
}

.content :global(.markdown) h6 {
font-size: 0.9em;
color: var(--vscode-descriptionForeground);
}

.content :global(.markdown) :first-child {
margin-top: 0;
}

/* --- Code block wrapper with header & copy button --- */

.content :global(.code-block-wrapper) {
margin: 8px 0;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--vscode-panel-border);
}

.content :global(.code-block-header) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background-color: var(--vscode-titleBar-activeBackground, var(--vscode-textCodeBlock-background));
border-bottom: 1px solid var(--vscode-panel-border);
min-height: 28px;
}

.content :global(.code-block-lang) {
font-size: 11px;
color: var(--vscode-descriptionForeground);
font-family: var(--vscode-font-family);
text-transform: lowercase;
}

.content :global(.code-block-copy) {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 2px;
border-radius: 3px;
margin-left: auto;
transition: color 0.15s;
}

.content :global(.code-block-copy.copied) {
color: var(--vscode-charts-green, #89d185);
}

.content :global(.code-block-wrapper:hover) :global(.code-block-copy) {
color: var(--vscode-foreground);
}

.content :global(.code-block-copy:hover) {
color: var(--vscode-foreground);
background-color: var(--vscode-toolbar-hoverBackground);
}

/* --- Reasoning part (Thinking) --- */
Expand Down
Loading