diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 183a5e02d1..ca81429f70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -845,12 +845,18 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 remark-math: specifier: ^6.0.0 version: 6.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 remove-markdown: specifier: ^0.6.4 version: 0.6.4 @@ -878,6 +884,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@4.3.2) + unified: + specifier: ^11.0.5 + version: 11.0.5 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -6517,6 +6526,9 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -7540,6 +7552,9 @@ packages: rehype-react@6.2.1: resolution: {integrity: sha512-f9KIrjktvLvmbGc7si25HepocOg4z0MuNOtweigKzBcDjiGSTGhyz6VSgaV5K421Cq1O+z4/oxRJ5G9owo0KVg==} + remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -15214,6 +15229,11 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-newline-to-break@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.2 + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -16502,6 +16522,12 @@ snapshots: '@mapbox/hast-util-table-cell-style': 0.2.1 hast-to-hyperscript: 9.0.1 + remark-breaks@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.5 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts new file mode 100644 index 0000000000..4f052f5544 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts @@ -0,0 +1,192 @@ +// npx vitest core/webview/__tests__/webviewMessageHandler.openFile.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as nodePath from "path" +import * as vscode from "vscode" +import { openFile } from "../../../integrations/misc/open-file" +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" +import type { Task } from "../../task/Task" + +vi.mock("../../../api/providers/fetchers/modelCache") + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + openTextDocument: vi.fn().mockResolvedValue({}), + }, + commands: { + executeCommand: vi.fn(), + }, +})) + +vi.mock("../../../i18n", () => ({ + // Echo the key with params serialized so tests can assert the full + // message arguments without loading a real i18n catalogue. + t: vi.fn((key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key)), +})) + +vi.mock("../../../utils/fs") +vi.mock("../../../utils/path") +vi.mock("../../../utils/globalContext") + +// Hand-rolled containment check mirroring isPathOutsideWorkspace, but resolving +// the workspace root too so the mock works on both POSIX and Windows test runs. +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn((filePath: string) => { + const nodePath = require("path") + const normalized = nodePath.resolve(filePath) + const workspaceRoot = nodePath.resolve("/mock/workspace") + if (normalized === workspaceRoot) return false + if (normalized.startsWith(workspaceRoot + nodePath.sep)) return false + return true + }), +})) + +vi.mock("../../mentions/resolveImageMentions", () => ({ + resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({ + text, + images: [...(images ?? [])], + })), +})) + +// Mock the openFile module so the test observes the handler's resolved path and +// proves markdown-sourced requests never reach out-of-workspace targets. +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn().mockResolvedValue(undefined), +})) + +const MOCK_CWD = "/mock/workspace/project" + +const mockProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + customModesManager: { + getCustomModes: vi.fn(), + deleteCustomMode: vi.fn(), + }, + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + contextProxy: { + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + setValue: vi.fn(), + getValue: vi.fn(), + }, + log: vi.fn(), + postStateToWebview: vi.fn(), + getCurrentTask: vi.fn().mockReturnValue({ cwd: MOCK_CWD }), + getTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + cwd: MOCK_CWD, +} as unknown as ClineProvider + +describe("webviewMessageHandler - openFile markdown workspace containment", () => { + beforeEach(() => { + vi.clearAllMocks() + // The containment logic only reads `cwd`; a full Task would be noise. The single + // assertion is safe because Task is structurally compatible with the stub. + vi.mocked(mockProvider.getCurrentTask).mockReturnValue({ cwd: MOCK_CWD } as Task) + ;(mockProvider as { cwd?: string }).cwd = MOCK_CWD + }) + + // MarkdownBlock tags its openFile posts with fromMarkdown, flagging the + // request as sourced from untrusted task markdown. + it("opens a markdown file within the workspace using a relative path", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { line: 3, fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(nodePath.resolve(MOCK_CWD, "src/index.ts"), { + line: 3, + fromMarkdown: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("rejects a markdown relative path that traverses outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "../../.env", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'common:errors.cannot_access_path:{"path":"../../.env","error":"common:errors.path_outside_workspace"}', + ) + }) + + it("rejects a markdown absolute path outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "/etc/passwd", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'common:errors.cannot_access_path:{"path":"/etc/passwd","error":"common:errors.path_outside_workspace"}', + ) + }) + + it("opens a markdown file using an absolute path within the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: `${MOCK_CWD}/src/index.ts`, + values: { fromMarkdown: true }, + }) + + expect(openFile).toHaveBeenCalledWith(`${MOCK_CWD}/src/index.ts`, { fromMarkdown: true }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + // First-party callers (slash-command settings, modes, MCP) are not flagged + // and keep the previous behavior, including global config files that live + // outside the workspace. + it("keeps legacy behavior for untagged callers opening paths outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "/global/roo/commands/my-command.md", + }) + + expect(openFile).toHaveBeenCalledWith("/global/roo/commands/my-command.md", undefined) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("does nothing when no path is provided", async () => { + await webviewMessageHandler(mockProvider, { + type: "openFile", + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("shows an error when no workspace cwd is available", async () => { + vi.mocked(mockProvider.getCurrentTask).mockReturnValue(undefined) + ;(mockProvider as { cwd?: string }).cwd = undefined + + await webviewMessageHandler(mockProvider, { + type: "openFile", + text: "src/index.ts", + values: { fromMarkdown: true }, + }) + + expect(openFile).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'common:errors.could_not_open_file:{"errorMessage":"common:errors.no_workspace"}', + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..e80e071653 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1515,13 +1515,46 @@ export const webviewMessageHandler = async ( } } break - case "openFile": - let filePath: string = message.text! + case "openFile": { + const rawPath = message.text || "" + if (!rawPath) { + break + } + // Task markdown links are untrusted, so markdown-sourced openFile + // requests (flagged by the webview with fromMarkdown) must resolve + // inside the current workspace. First-party callers (modes, MCP, + // slash-command settings) may legitimately open global config files + // outside the workspace, so they keep the previous behavior. + const fromMarkdown = message.values?.fromMarkdown === true + let filePath = rawPath if (!path.isAbsolute(filePath)) { - filePath = path.join(getCurrentCwd(), filePath) + const cwd = getCurrentCwd() + if (!cwd) { + void vscode.window.showErrorMessage( + t("common:errors.could_not_open_file", { errorMessage: t("common:errors.no_workspace") }), + ) + break + } + filePath = path.resolve(cwd, filePath) + } + // Workspace-boundary validation (defense in depth): the webview already + // rejects traversal in markdown anchors, but refuse any markdown path + // that still resolves outside the workspace. + if (fromMarkdown && isPathOutsideWorkspace(filePath)) { + void vscode.window.showErrorMessage( + t("common:errors.cannot_access_path", { + path: rawPath, + error: t("common:errors.path_outside_workspace"), + }), + ) + break } - await openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) + await openFile( + filePath, + message.values as { create?: boolean; content?: string; line?: number; fromMarkdown?: boolean }, + ) break + } case "readFileContent": { const relPath = message.text || "" if (!relPath) { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 24ae3f310c..a85c19cec4 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -59,6 +59,7 @@ "failed_remove_directory": "Ha fallat l'eliminació del directori de tasques: {{error}}", "custom_storage_path_unusable": "La ruta d'emmagatzematge personalitzada \"{{path}}\" no és utilitzable, s'utilitzarà la ruta predeterminada", "cannot_access_path": "No es pot accedir a la ruta {{path}}: {{error}}", + "path_outside_workspace": "La ruta és fora de l'espai de treball", "settings_import_failed": "Ha fallat la importació de la configuració: {{error}}.", "mistake_limit_guidance": "Això pot indicar un error en el procés de pensament del model o la incapacitat d'utilitzar una eina correctament, que es pot mitigar amb orientació de l'usuari (p. ex. \"Prova de dividir la tasca en passos més petits\").", "violated_organization_allowlist": "Ha fallat l'execució de la tasca: el perfil actual no és compatible amb la configuració de la teva organització", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 54fa0b3c22..9f2b6d2056 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Fehler beim Entfernen des Aufgabenverzeichnisses: {{error}}", "custom_storage_path_unusable": "Benutzerdefinierter Speicherpfad \"{{path}}\" ist nicht verwendbar, Standardpfad wird verwendet", "cannot_access_path": "Zugriff auf Pfad {{path}} nicht möglich: {{error}}", + "path_outside_workspace": "Pfad liegt außerhalb des Arbeitsbereichs", "settings_import_failed": "Fehler beim Importieren der Einstellungen: {{error}}.", "mistake_limit_guidance": "Dies kann auf einen Fehler im Denkprozess des Modells oder die Unfähigkeit hinweisen, ein Tool richtig zu verwenden, was durch Benutzerführung behoben werden kann (z.B. \"Versuche, die Aufgabe in kleinere Schritte zu unterteilen\").", "violated_organization_allowlist": "Aufgabe konnte nicht ausgeführt werden: Das aktuelle Profil ist nicht kompatibel mit den Einstellungen deiner Organisation", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 516a3d4f88..507780366a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Failed to remove task directory: {{error}}", "custom_storage_path_unusable": "Custom storage path \"{{path}}\" is unusable, will use default path", "cannot_access_path": "Cannot access path {{path}}: {{error}}", + "path_outside_workspace": "Path is outside the workspace", "settings_import_failed": "Settings import failed: {{error}}.", "mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").", "violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 71dc994516..f646f5c4f3 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Error al eliminar el directorio de tareas: {{error}}", "custom_storage_path_unusable": "La ruta de almacenamiento personalizada \"{{path}}\" no es utilizable, se usará la ruta predeterminada", "cannot_access_path": "No se puede acceder a la ruta {{path}}: {{error}}", + "path_outside_workspace": "La ruta está fuera del espacio de trabajo", "settings_import_failed": "Error al importar la configuración: {{error}}.", "mistake_limit_guidance": "Esto puede indicar un fallo en el proceso de pensamiento del modelo o la incapacidad de usar una herramienta correctamente, lo cual puede mitigarse con orientación del usuario (ej. \"Intenta dividir la tarea en pasos más pequeños\").", "violated_organization_allowlist": "Error al ejecutar la tarea: el perfil actual no es compatible con la configuración de tu organización", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 87009ee988..0e5f59ca1e 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Échec de la suppression du répertoire de tâches : {{error}}", "custom_storage_path_unusable": "Le chemin de stockage personnalisé \"{{path}}\" est inutilisable, le chemin par défaut sera utilisé", "cannot_access_path": "Impossible d'accéder au chemin {{path}} : {{error}}", + "path_outside_workspace": "Le chemin est à l'extérieur de l'espace de travail", "settings_import_failed": "Échec de l'importation des paramètres : {{error}}", "mistake_limit_guidance": "Cela peut indiquer un échec dans le processus de réflexion du modèle ou une incapacité à utiliser un outil correctement, ce qui peut être atténué avec des conseils de l'utilisateur (par ex. \"Essaie de diviser la tâche en étapes plus petites\").", "violated_organization_allowlist": "Échec de l'exécution de la tâche : le profil actuel n'est pas compatible avec les paramètres de votre organisation", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f4bd1c3055..59cf0f0657 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "टास्क डायरेक्टरी हटाने में विफल: {{error}}", "custom_storage_path_unusable": "कस्टम स्टोरेज पाथ \"{{path}}\" उपयोग योग्य नहीं है, डिफ़ॉल्ट पाथ का उपयोग किया जाएगा", "cannot_access_path": "पाथ {{path}} तक पहुंच नहीं पा रहे हैं: {{error}}", + "path_outside_workspace": "पथ वर्कस्पेस से बाहर है", "settings_import_failed": "सेटिंग्स इम्पोर्ट करने में विफल: {{error}}।", "mistake_limit_guidance": "यह मॉडल की सोच प्रक्रिया में विफलता या किसी टूल का सही उपयोग न कर पाने का संकेत हो सकता है, जिसे उपयोगकर्ता के मार्गदर्शन से ठीक किया जा सकता है (जैसे \"कार्य को छोटे चरणों में बांटने की कोशिश करें\")।", "violated_organization_allowlist": "कार्य चलाने में विफल: वर्तमान प्रोफ़ाइल आपके संगठन की सेटिंग्स के साथ संगत नहीं है", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index bcee321af5..87ce78d209 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Gagal menghapus direktori tugas: {{error}}", "custom_storage_path_unusable": "Path penyimpanan kustom \"{{path}}\" tidak dapat digunakan, akan menggunakan path default", "cannot_access_path": "Tidak dapat mengakses path {{path}}: {{error}}", + "path_outside_workspace": "Path berada di luar workspace", "settings_import_failed": "Impor pengaturan gagal: {{error}}.", "mistake_limit_guidance": "Ini mungkin menunjukkan kegagalan dalam proses pemikiran model atau ketidakmampuan untuk menggunakan tool dengan benar, yang dapat diatasi dengan beberapa panduan pengguna (misalnya \"Coba bagi tugas menjadi langkah-langkah yang lebih kecil\").", "violated_organization_allowlist": "Gagal menjalankan tugas: profil saat ini tidak kompatibel dengan pengaturan organisasi kamu", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 395be16b84..ce7ffce090 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Impossibile rimuovere la directory delle attività: {{error}}", "custom_storage_path_unusable": "Il percorso di archiviazione personalizzato \"{{path}}\" non è utilizzabile, verrà utilizzato il percorso predefinito", "cannot_access_path": "Impossibile accedere al percorso {{path}}: {{error}}", + "path_outside_workspace": "Il percorso è fuori dall'area di lavoro", "settings_import_failed": "Importazione delle impostazioni fallita: {{error}}.", "mistake_limit_guidance": "Questo può indicare un fallimento nel processo di pensiero del modello o l'incapacità di utilizzare correttamente uno strumento, che può essere mitigato con la guida dell'utente (ad es. \"Prova a suddividere l'attività in passaggi più piccoli\").", "violated_organization_allowlist": "Impossibile eseguire l'attività: il profilo corrente non è compatibile con le impostazioni della tua organizzazione", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7dccfcd837..d412e8f8e0 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "タスクディレクトリの削除に失敗しました:{{error}}", "custom_storage_path_unusable": "カスタムストレージパス \"{{path}}\" が使用できないため、デフォルトパスを使用します", "cannot_access_path": "パス {{path}} にアクセスできません:{{error}}", + "path_outside_workspace": "パスはワークスペースの外にあります", "settings_import_failed": "設定のインポートに失敗しました:{{error}}", "mistake_limit_guidance": "これは、モデルの思考プロセスの失敗やツールを適切に使用できないことを示している可能性があり、ユーザーのガイダンスによって軽減できます(例:「タスクをより小さなステップに分割してみてください」)。", "violated_organization_allowlist": "タスクの実行に失敗しました: 現在のプロファイルは組織の設定と互換性がありません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0ca65be687..ff2fcb10d9 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "작업 디렉토리 제거 실패: {{error}}", "custom_storage_path_unusable": "사용자 지정 저장 경로 \"{{path}}\"를 사용할 수 없어 기본 경로를 사용합니다", "cannot_access_path": "경로 {{path}}에 접근할 수 없습니다: {{error}}", + "path_outside_workspace": "경로가 작업 영역 밖에 있습니다", "settings_import_failed": "설정 가져오기 실패: {{error}}.", "mistake_limit_guidance": "이는 모델의 사고 과정 실패나 도구를 제대로 사용하지 못하는 것을 나타낼 수 있으며, 사용자 가이드를 통해 완화할 수 있습니다 (예: \"작업을 더 작은 단계로 나누어 시도해보세요\").", "violated_organization_allowlist": "작업 실행 실패: 현재 프로필이 조직 설정과 호환되지 않습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index a38415edfd..e8b639af5d 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Verwijderen van taakmap mislukt: {{error}}", "custom_storage_path_unusable": "Aangepast opslagpad \"{{path}}\" is onbruikbaar, standaardpad wordt gebruikt", "cannot_access_path": "Kan pad {{path}} niet openen: {{error}}", + "path_outside_workspace": "Pad is buiten de werkomgeving", "settings_import_failed": "Importeren van instellingen mislukt: {{error}}.", "mistake_limit_guidance": "Dit kan duiden op een fout in het denkproces van het model of het onvermogen om een tool correct te gebruiken, wat kan worden verminderd met gebruikersbegeleiding (bijv. \"Probeer de taak op te delen in kleinere stappen\").", "violated_organization_allowlist": "Taak uitvoeren mislukt: het huidige profiel is niet compatibel met de instellingen van uw organisatie", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index ff898e8987..73e2293a10 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Nie udało się usunąć katalogu zadania: {{error}}", "custom_storage_path_unusable": "Niestandardowa ścieżka przechowywania \"{{path}}\" nie jest użyteczna, zostanie użyta domyślna ścieżka", "cannot_access_path": "Nie można uzyskać dostępu do ścieżki {{path}}: {{error}}", + "path_outside_workspace": "Ścieżka znajduje się poza obszarem roboczym", "settings_import_failed": "Nie udało się zaimportować ustawień: {{error}}.", "mistake_limit_guidance": "To może wskazywać na błąd w procesie myślowym modelu lub niezdolność do prawidłowego użycia narzędzia, co można złagodzić poprzez wskazówki użytkownika (np. \"Spróbuj podzielić zadanie na mniejsze kroki\").", "violated_organization_allowlist": "Nie udało się uruchomić zadania: bieżący profil nie jest kompatybilny z ustawieniami Twojej organizacji", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index d3c31ed2dd..82654929b4 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -59,6 +59,7 @@ "failed_remove_directory": "Falha ao remover o diretório de tarefas: {{error}}", "custom_storage_path_unusable": "O caminho de armazenamento personalizado \"{{path}}\" não pode ser usado, será usado o caminho padrão", "cannot_access_path": "Não é possível acessar o caminho {{path}}: {{error}}", + "path_outside_workspace": "O caminho está fora do espaço de trabalho", "settings_import_failed": "Falha ao importar configurações: {{error}}", "mistake_limit_guidance": "Isso pode indicar uma falha no processo de pensamento do modelo ou incapacidade de usar uma ferramenta adequadamente, o que pode ser mitigado com orientação do usuário (ex. \"Tente dividir a tarefa em etapas menores\").", "violated_organization_allowlist": "Falha ao executar a tarefa: o perfil atual não é compatível com as configurações da sua organização", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 08d2e2aa2c..5857b1290f 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Не удалось удалить директорию задачи: {{error}}", "custom_storage_path_unusable": "Пользовательский путь хранения \"{{path}}\" непригоден, будет использован путь по умолчанию", "cannot_access_path": "Невозможно получить доступ к пути {{path}}: {{error}}", + "path_outside_workspace": "Путь находится вне рабочего пространства", "settings_import_failed": "Не удалось импортировать настройки: {{error}}.", "mistake_limit_guidance": "Это может указывать на сбой в процессе мышления модели или неспособность правильно использовать инструмент, что можно смягчить с помощью руководства пользователя (например, \"Попробуйте разбить задачу на более мелкие шаги\").", "violated_organization_allowlist": "Не удалось выполнить задачу: текущий профиль несовместим с настройками вашей организации", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 716ccbc6de..48faf7d03c 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Görev dizini kaldırılamadı: {{error}}", "custom_storage_path_unusable": "Özel depolama yolu \"{{path}}\" kullanılamıyor, varsayılan yol kullanılacak", "cannot_access_path": "{{path}} yoluna erişilemiyor: {{error}}", + "path_outside_workspace": "Yol çalışma alanının dışında", "settings_import_failed": "Ayarlar içe aktarılamadı: {{error}}.", "mistake_limit_guidance": "Bu, modelin düşünce sürecindeki bir başarısızlığı veya bir aracı düzgün kullanamama durumunu gösterebilir, bu da kullanıcı rehberliği ile hafifletilebilir (örn. \"Görevi daha küçük adımlara bölmeyi deneyin\").", "violated_organization_allowlist": "Görev yürütülemedi: Geçerli profil kuruluşunuzun ayarlarıyla uyumlu değil", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 69c6343c31..5651e0eb3b 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "Không thể xóa thư mục nhiệm vụ: {{error}}", "custom_storage_path_unusable": "Đường dẫn lưu trữ tùy chỉnh \"{{path}}\" không thể sử dụng được, sẽ sử dụng đường dẫn mặc định", "cannot_access_path": "Không thể truy cập đường dẫn {{path}}: {{error}}", + "path_outside_workspace": "Đường dẫn nằm ngoài không gian làm việc", "settings_import_failed": "Nhập cài đặt thất bại: {{error}}.", "mistake_limit_guidance": "Điều này có thể cho thấy sự thất bại trong quá trình suy nghĩ của mô hình hoặc không thể sử dụng công cụ đúng cách, có thể được giảm thiểu bằng hướng dẫn của người dùng (ví dụ: \"Hãy thử chia nhỏ nhiệm vụ thành các bước nhỏ hơn\").", "violated_organization_allowlist": "Không thể chạy tác vụ: hồ sơ hiện tại không tương thích với cài đặt của tổ chức của bạn", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 3600f0aa7c..e5ecf86da2 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -60,6 +60,7 @@ "failed_remove_directory": "删除任务目录失败:{{error}}", "custom_storage_path_unusable": "自定义存储路径 \"{{path}}\" 不可用,将使用默认路径", "cannot_access_path": "无法访问路径 {{path}}:{{error}}", + "path_outside_workspace": "路径位于工作区之外", "settings_import_failed": "设置导入失败:{{error}}。", "mistake_limit_guidance": "这可能表明模型思维过程失败或无法正确使用工具,可通过用户指导来缓解(例如\"尝试将任务分解为更小的步骤\")。", "violated_organization_allowlist": "执行任务失败:当前配置文件与您的组织设置不兼容", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c635769891..e70c464da0 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -55,6 +55,7 @@ "failed_remove_directory": "刪除工作目錄失敗:{{error}}", "custom_storage_path_unusable": "自訂儲存路徑 \"{{path}}\" 無法使用,將使用預設路徑", "cannot_access_path": "無法存取路徑 {{path}}:{{error}}", + "path_outside_workspace": "路徑位於工作區之外", "settings_import_failed": "設定匯入失敗:{{error}}。", "mistake_limit_guidance": "這可能表明模型思維過程失敗或無法正確使用工具,可透過使用者指導來緩解(例如「嘗試將工作分解為更小的步驟」)。", "violated_organization_allowlist": "執行工作失敗:目前設定檔與您的組織設定不相容", diff --git a/webview-ui/package.json b/webview-ui/package.json index 450288eff5..df7461368e 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -67,8 +67,10 @@ "react-use": "^17.5.1", "react-virtuoso": "^4.7.13", "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "remove-markdown": "^0.6.4", "shell-quote": "^1.8.2", "shiki": "^3.2.1", @@ -78,6 +80,7 @@ "tailwind-merge": "^3.0.0", "tailwindcss": "^4.0.0", "tailwindcss-animate": "^1.0.7", + "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "use-sound": "^5.0.0", "vscode-material-icons": "^0.1.1", diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 04ca36b2c6..a2f357d501 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -181,6 +181,66 @@ export const stories: Record = { ) }, + "task-header-markdown": async () => { + const [{ AppProviders }, { default: TaskHeader }] = await Promise.all([ + import("../AppProviders"), + import("@/components/chat/TaskHeader"), + ]) + // Representative user-authored prompt: heading, bold, inline code, a + // bullet list, an external link, soft breaks (single newlines), and the + // three mention kinds (path, @problems, @terminal). The length keeps the + // expanded prompt box (max-h-80) overflowing so the scrollbar surface is + // captured by the snapshot. + const prompt = [ + "# Refactor the billing module", + "", + "Update the invoice total in `src/billing/invoice.ts` so that", + "refunds apply before tax. Keep the **public API** stable while", + "moving the rounding logic into a shared helper.", + "", + "- Recalculate totals in `calculateInvoiceTotal`", + "- Emit the warning listed in @problems after the first failing assertion", + "- Re-run the demo script with @terminal", + "- Keep the behavior documented in the [billing design notes](https://example.com/docs/billing)", + "", + "Watch the edge cases in @/src/billing/invoice.ts, especially", + "the package-style exports and the cached total memo.", + "", + "The refactor should ship behind a feature flag and the", + "rollback path must stay one command away.", + ].join("\n") + const ts = 1755000000000 + return ( + +
+ undefined} + /> +
+
+ ) + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..2985c00425 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -29,6 +29,8 @@ import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import MarkdownBlock from "../common/MarkdownBlock" + export interface TaskHeaderProps { task: ClineMessage tokensIn: number @@ -163,7 +165,11 @@ const TaskHeader = ({ e.target.closest('[role="button"]') || e.target.closest("[data-radix-popper-content-wrapper]") || e.target.closest("img") || - e.target.tagName === "IMG") + // Stryker disable next-line ConditionalExpression,StringLiteral: a click on the itself is already caught by closest("img") above; the tagName backstop adds no distinct behavior + e.target.tagName === "IMG" || + e.target.closest("a") || + // Stryker disable next-line ConditionalExpression,StringLiteral: a click on the itself is already caught by closest("a") above; the tagName backstop adds no distinct behavior + e.target.tagName === "A") ) { return } @@ -324,13 +330,13 @@ const TaskHeader = ({ className="text-vscode-font-size overflow-y-auto break-words break-anywhere relative">
- +
{task.images && task.images.length > 0 && } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 022186f40e..be3a4ae8a9 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -330,4 +330,179 @@ describe("TaskHeader", () => { expect(screen.getByText("25%")).toBeInTheDocument() }) }) + + describe("Expanded task text markdown rendering", () => { + it("shows raw source while collapsed and formatted markdown when expanded", async () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "**bold** and `code`", images: [] }, + }) + + // Collapsed state renders the raw task text (no markdown formatting yet). + expect(screen.getByText("**bold** and `code`")).toBeInTheDocument() + expect(container.querySelector("strong")).toBeNull() + + // Expand the header by clicking the collapsed title. + fireEvent.click(screen.getByText("**bold** and `code`")) + + // Expanded state applies markdown: **bold** becomes , `code` becomes . + const bold = await screen.findByText("bold") + expect(bold.tagName).toBe("STRONG") + expect(container.querySelector("code")?.textContent).toBe("code") + + // The raw markdown source must not be displayed verbatim in the expanded view. + expect(screen.queryByText("**bold** and `code`")).not.toBeInTheDocument() + }) + + it("uses the shared scrollable style for the expanded prompt box", () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "prompt", images: [] }, + }) + + // Expand the header. + fireEvent.click(screen.getByText("prompt")) + + // The prompt box must use the VS Code-style .scrollable scrollbar (hover-reveal), + // not a default always-visible Chromium scrollbar, so it matches the message list. + const scrollBox = container.querySelector(".scrollable") + expect(scrollBox).not.toBeNull() + expect(scrollBox?.className).toContain("max-h-80") + }) + + it("renders headings and lists in the expanded view", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "# Heading\n- item one\n- item two", + images: [], + }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + const heading = await screen.findByRole("heading") + expect(heading.textContent).toBe("Heading") + expect(container.querySelector("ul li")).not.toBeNull() + }) + + it("does not collapse the panel when a rendered markdown link is clicked", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "**bold** [example](https://example.com)", + images: [], + }, + }) + + // Expand the header. + fireEvent.click(screen.getByText("**bold** [example](https://example.com)")) + const link = await screen.findByRole("link", { name: "example" }) + + // Clicking a rendered link must not toggle isTaskExpanded (the header click + // handler ignores anchor targets), so the expanded content stays visible. + fireEvent.click(link) + expect(container.querySelector("strong")).not.toBeNull() + }) + + it("keeps context mentions clickable in the expanded markdown view", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "Inspect @/src/file.ts, @problems, and @terminal.", + images: [], + }, + }) + + // Expand via the header container because the collapsed title contains split mention spans. + fireEvent.click(container.querySelector(".cursor-pointer")!) + await screen.findByText(/Inspect/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(3) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + expect(mentions[2].textContent).toBe("@terminal") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/file.ts" }) + + // The mention click must not bubble to the header toggle (the mention handler + // stops propagation), so the expanded markdown stays rendered after the + // mention is opened instead of the panel collapsing. + expect(screen.getByText(/Inspect/, { exact: false })).toBeInTheDocument() + expect(container.querySelectorAll("span.mention-context-highlight")).toHaveLength(3) + }) + + it("keeps single newlines as line breaks in a plain-text prompt", async () => { + const { container } = renderTaskHeader({ + task: { type: "say", ts: Date.now(), text: "Fix the login bug\nIt crashes on startup", images: [] }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + // Inexact match: the soft break splits the paragraph into text
text, so no + // single element's full text equals the first line. + await screen.findByText(/Fix the login bug/, { exact: false }) + + // The previous expanded view rendered plain text with whitespace-pre-wrap, so a + // single newline was always a line break. The markdown pipeline collapses soft + // breaks to spaces per CommonMark unless remark-breaks is enabled, so the header + // must keep the newline structural (
) instead of reflowing the prompt into + // one paragraph. + const paragraph = container.querySelector(".scrollable p") + expect(paragraph).not.toBeNull() + expect(paragraph?.querySelector("br")).not.toBeNull() + expect(paragraph?.textContent).toBe("Fix the login bugIt crashes on startup") + }) + + it("still parses markdown headings and lists while keeping newlines inside them", async () => { + const { container } = renderTaskHeader({ + task: { + type: "say", + ts: Date.now(), + text: "# Heading\n- item one\n continued line\n- item two", + images: [], + }, + }) + + // Expand via the header container (the raw multi-line title is not a stable text target). + fireEvent.click(container.querySelector(".cursor-pointer")!) + + const heading = await screen.findByRole("heading") + expect(heading.textContent).toBe("Heading") + + // Markdown still parses (the # line is a heading, the - lines are list items)... + const items = container.querySelectorAll(".scrollable li") + expect(items).toHaveLength(2) + + // ...and the soft break inside the first item renders as a line break. + expect(items[0]?.querySelector("br")).not.toBeNull() + expect(items[0]?.textContent).toBe("item onecontinued line") + expect(items[1]?.textContent).toBe("item two") + }) + + it("renders an empty prompt without crashing", () => { + const { container } = renderTaskHeader({ + // `text` is optional on ClineMessage; omit it to exercise the empty-prompt path. + task: { type: "say", ts: Date.now(), images: [] }, + }) + + // No title text to click, so expand via the header container itself. + fireEvent.click(container.querySelector(".cursor-pointer")!) + + // The empty prompt renders nothing but must not crash; the rest of the + // expanded header (cost row) is still present. + expect(screen.getByText("$0.05")).toBeInTheDocument() + + // The expanded text area stays empty: a missing prompt must not render + // placeholder content into the markdown container. + const textArea = container.querySelector(".scrollable") + expect(textArea).not.toBeNull() + expect(textArea?.textContent).toBe("") + }) + }) }) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.visual.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.visual.tsx new file mode 100644 index 0000000000..50cf62c416 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.visual.tsx @@ -0,0 +1,43 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +// Pixel receipts for the expanded TaskHeader markdown surface (PR #1257): +// markdown formatting, clickable mentions, soft breaks, and the consistent +// .scrollable overflow box. Semantic behavior (toggle guards, openMention +// posts, boundary rules) stays covered by TaskHeader.spec.tsx. +for (const theme of visualThemes) { + test(`renders the expanded TaskHeader prompt as markdown in the ${theme.name} theme`, async ({ mount, page }) => { + const component = mountedStory(await mount("task-header-markdown")) + await applyVisualTheme(page, theme) + + // Establish the expanded state deterministically through the header + // toggle (lucide chevron-down while collapsed). + await component.locator("button:has(svg.lucide-chevron-down)").click() + + // The expanded view applies markdown: heading, list, mentions, and + // soft breaks rendered as
. + await expect(component.getByRole("heading", { name: "Refactor the billing module" })).toBeVisible() + expect(await component.locator("ul li").count()).toBe(4) + const mentions = component.locator('span.mention-context-highlight[role="button"]') + expect(await mentions.count()).toBe(3) + await expect(mentions.nth(0)).toHaveText("@problems") + await expect(mentions.nth(1)).toHaveText("@terminal") + await expect(mentions.nth(2)).toHaveText("@/src/billing/invoice.ts") + expect(await component.locator("p br").count()).toBeGreaterThan(0) + + // The prompt overflows the max-h-80 box, so the snapshot captures the + // clipped, scrollable region using the shared .scrollable (VS Code-style + // scrollbar) surface. + const scrollBox = component.locator(".scrollable") + expect(await scrollBox.count()).toBe(1) + await expect(scrollBox).toHaveClass(/max-h-80/) + const { scrollHeight, clientHeight } = await scrollBox.evaluate((el) => ({ + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + })) + expect(scrollHeight).toBeGreaterThan(clientHeight) + + await expect(component).toHaveScreenshot(`task-header-markdown-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-dark.png new file mode 100644 index 0000000000..b30e104b86 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast-light.png new file mode 100644 index 0000000000..124f5d47e4 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast.png b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast.png new file mode 100644 index 0000000000..62a8ffa79a Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-high-contrast.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-light.png new file mode 100644 index 0000000000..e4f5bbb741 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/task-header-markdown-light.png differ diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx index 02f696553f..bddef6db83 100644 --- a/webview-ui/src/components/common/MarkdownBlock.tsx +++ b/webview-ui/src/components/common/MarkdownBlock.tsx @@ -3,8 +3,13 @@ import ReactMarkdown from "react-markdown" import styled from "styled-components" import { visit } from "unist-util-visit" import rehypeKatex from "rehype-katex" -import remarkMath from "remark-math" +import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" +import remarkMath from "remark-math" +import remarkParse from "remark-parse" +import { unified } from "unified" + +import { mentionRegexGlobal } from "@roo/context-mentions" import { vscode } from "@src/utils/vscode" import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown" @@ -12,6 +17,262 @@ import { type AlertType, remarkGithubAlerts } from "@src/utils/markdown" import CodeBlock from "./CodeBlock" import MermaidBlock from "./MermaidBlock" +// Control character that wraps a mention index in the preprocessed markdown. +// It cannot be typed into a prompt and carries no markdown meaning, so remark +// always keeps a whole placeholder inside a single text node. Built via +// `new RegExp` from a string constant (a template literal) so the control +// character does not appear in a regex literal (no-control-regex). +// Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests +const MENTION_PLACEHOLDER_CHAR = "\u0001" +// Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests +const MENTION_PLACEHOLDER_REGEX = new RegExp(`${MENTION_PLACEHOLDER_CHAR}(\\d+)${MENTION_PLACEHOLDER_CHAR}`, "g") + +// mdast node types whose raw source regions must never be mention-rewritten: +// code blocks (fenced or indented), inline code, links, images, raw HTML, math, +// reference link definitions, and reference links/images all render as literal +// or non-text content. Rewriting a definition's destination would corrupt the +// reference link's href; rewriting a reference label or alt would leak the raw +// placeholder into the anchor text or img alt (rehypeMentions skips anchors), +// instead of producing a mention span. +// Stryker disable next-line ArrayDeclaration: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests +const MENTION_MASK_NODE_TYPES = new Set([ + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "code", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "inlineCode", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "link", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "image", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "html", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "inlineMath", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "math", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "definition", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "linkReference", + // Stryker disable next-line StringLiteral: module-scope static; Stryker 10.0.0 vitest-runner skips static activation when testFiles is set (false survivor); pinned by mask tests + "imageReference", +]) + +/** + * Rewrite mention patterns in the RAW markdown string before remark tokenizes + * it, replacing each match with an indexed placeholder. + * + * Matching on remark's tokenized text nodes truncates paths that contain + * markdown-active characters: `@/src/__init__.py` is parsed as + * `@/src/` + init + `.py`, so per-node matching would only + * see `@/src/` and post the wrong path to `openMention`. Raw-string matching + * is also the behavior of the collapsed component, so this restores + * it for the expanded view. + * + * Matching runs on the raw string so the shared regex's boundary rules apply + * unchanged (replacing literal regions with spaces would turn a preceding `)` + * or backtick into whitespace and make non-mentions actionable). Literal / non- + * text regions (code, links, images, HTML, math, reference link definitions, + * and reference links/images) are marked via a throwaway mdast parse with the + * exact positions remark sees, and a match whose range intersects one of them + * is discarded so mentions inside such regions stay inert. + */ +function prepareMentions(markdown: string): { preparedMarkdown: string; mentions: string[] } { + // Stryker disable next-line ConditionalExpression: equivalent: parsing empty input yields the same result as the early return and no mention regex match is possible on an empty string + if (!markdown) { + // Stryker disable next-line ObjectLiteral,ArrayDeclaration: equivalent for empty markdown: an empty or undefined preparedMarkdown renders nothing and the empty tree has no placeholders for rehypeMentions to index + return { preparedMarkdown: markdown, mentions: [] } + } + + // A throwaway parse with the same extensions as the render pipeline, so the + // reported positions match what remark will tokenize. Mark literal and + // non-text regions (mdast positions carry absolute source offsets): a + // mention inside any of them must stay inert, because code and links render + // as literal/interactive content, images, raw HTML, and math keep their + // source text unchanged, a reference link definition's destination becomes + // the link's href, and a reference link/image label or alt renders as the + // anchor text or img alt (rewriting any of them would corrupt the href/alt + // or leak the raw placeholder into the rendered output). + const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(markdown) + + const isMasked = new Array(markdown.length).fill(false) + visit(tree, (node: any) => { + if (!MENTION_MASK_NODE_TYPES.has(node.type)) { + return + } + // Stryker disable next-line OptionalChaining: equivalent: remark always reports position.start.offset for parsed nodes; optional chaining only defends against a shape remark never emits + const start = node.position?.start?.offset + // Stryker disable next-line OptionalChaining: equivalent: remark always reports position.end.offset for parsed nodes; optional chaining only defends against a shape remark never emits + const end = node.position?.end?.offset + // Stryker disable next-line ConditionalExpression,LogicalOperator,BlockStatement: defensive: remark position offsets are always numeric, so the guard and its body are unreachable in well-formed output (NoCoverage) + if (typeof start !== "number" || typeof end !== "number") { + return + } + // Stryker disable next-line EqualityOperator,ConditionalExpression: equivalent: i < end already bounds i below isMasked.length, and the mention regex boundary lookbehind prevents a later match from starting at a previous match end offset + for (let i = start; i < end && i < isMasked.length; i++) { + isMasked[i] = true + } + }) + + // Stryker disable next-line ArrayDeclaration: equivalent: placeholder indices are computed as mentions.length - 1 after each push, so a junk initial element shifts all indices consistently and index 0 is never referenced + const mentions: string[] = [] + let preparedMarkdown = "" + let lastIndex = 0 + for (const match of markdown.matchAll(mentionRegexGlobal)) { + const start = match.index! + const end = start + match[0].length + // The raw string (not a masked copy) is what the shared regex's boundary + // rules must see: masking would turn a preceding `)` or backtick into a + // space and make a non-mention actionable (e.g. `[file](/src/a.ts)`@problems``). + // Discard a match only when its range lands inside a masked literal region. + // Stryker disable next-line MethodExpression: equivalent: mention matches are word-boundary delimited while masked regions begin and end on non-word syntax characters, so an overlapping range is fully inside or outside and some/every agree + if (isMasked.slice(start, end).some(Boolean)) { + continue + } + preparedMarkdown += markdown.slice(lastIndex, start) + mentions.push(markdown.slice(start, end)) + preparedMarkdown += `${MENTION_PLACEHOLDER_CHAR}${mentions.length - 1}${MENTION_PLACEHOLDER_CHAR}` + lastIndex = end + } + preparedMarkdown += markdown.slice(lastIndex) + + return { preparedMarkdown, mentions } +} + +/** + * Rehype plugin that replaces the mention placeholders produced by + * prepareMentions with clickable spans matching the styling used by the + * collapsed Mention component. + */ +function rehypeMentions(mentions: string[]) { + return (tree: any) => { + // Stryker disable next-line StringLiteral: equivalent (empirically verified): in this unist-util stack an empty node-type test degenerates to visit-all and the visitor no-ops on non-text nodes, so behavior is unchanged + visit(tree, "text", (node: any, index: number | undefined, parent: any) => { + // Stryker disable next-line ConditionalExpression,LogicalOperator,BlockStatement: defensive: unist-util-visit always provides index and parent for non-root nodes and a text node can never be the tree root, so the guard body is unreachable + if (index === undefined || !parent) { + return + } + + // Skip text inside spans we already created (the visitor may revisit + // children inserted during the same pass). + // Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator,OptionalChaining,StringLiteral,BlockStatement: defensive: spans created in this pass carry plain mention text without placeholders and the visitor does not revisit inserted children, so the guard can never change output + if (parent?.tagName === "span" && parent.properties?.className?.includes("mention-context-highlight")) { + return + } + + // prepareMentions already masks code and link regions, but keep these + // guards so the plugin stays safe on any tree: inside
a role=button + // span would be invalid nested interactive content (WHATWG) and its + // stopPropagation would block the anchor's own openFile handler; inside + // code it would corrupt the CodeBlock text extraction, which only keeps + // string children (the mention text would silently disappear). + // Stryker disable next-line ConditionalExpression,LogicalOperator,OptionalChaining,StringLiteral,BlockStatement: defensive: prepareMentions masks code/inlineCode/link source regions so no placeholder can exist inside code, pre, or a; the guard is a second line of defense + if (parent?.tagName === "code" || parent?.tagName === "pre" || parent?.tagName === "a") { + return + } + + const originalValue = String(node.value) + const matches = Array.from(originalValue.matchAll(MENTION_PLACEHOLDER_REGEX)) + + // Stryker disable next-line ConditionalExpression,BlockStatement: equivalent: with no placeholder matches the fall-through rebuilds the text node with its own unchanged value (or drops an empty text node), which renders identically + if (matches.length === 0) { + return + } + + // If any placeholder fails to resolve (should not happen), leave the + // text untouched instead of rendering the control characters verbatim. + // Stryker disable next-line ConditionalExpression,MethodExpression,ArrowFunction,BlockStatement: defensive: placeholders are created by prepareMentions with self-consistent indices into this same mentions array, so an unresolvable index is unreachable (NoCoverage) + if (matches.some((match) => mentions[Number(match[1])] === undefined)) { + return + } + + const children: any[] = [] + let lastIndex = 0 + + for (const match of matches) { + const mentionText = mentions[Number(match[1])] + // The raw mention includes the leading "@"; the posted value is the + // full path/word after it, matching the collapsed Mention component. + const mentionValue = mentionText.slice(1) + const mentionStart = match.index! + + // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent: a zero-length gap only pushes an empty text node which renders nothing, and matches are ordered so mentionStart is never below lastIndex + if (mentionStart > lastIndex) { + children.push({ type: "text", value: originalValue.slice(lastIndex, mentionStart) }) + } + + children.push({ + type: "element", + tagName: "span", + properties: { + className: ["mention-context-highlight", "text-[0.9em]", "cursor-pointer"], + role: "button", + tabIndex: 0, + onClick: (event: React.MouseEvent) => { + // Keep mention clicks from bubbling to the TaskHeader toggle, which + // would collapse the expanded panel right after opening the mention. + // Stryker disable next-line CallExpression: equivalent: TaskHeader's root onClick early-returns when e.target matches closest('[role=button]') (the mention span itself) and no intermediate ancestor handles clicks + event.stopPropagation() + vscode.postMessage({ type: "openMention", text: mentionValue }) + }, + // Keyboard parity with the click handler (a role=button span is not a + // native button, so Enter/Space must be handled explicitly). + // preventDefault keeps Space from also scrolling the expanded task panel, + // which otherwise receives the key's default action when a mention has + // focus. + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return + } + event.preventDefault() + // Stryker disable next-line CallExpression: equivalent: no ancestor in the MarkdownBlock/TaskHeader tree registers a keydown handler and preventDefault already suppresses the default scroll action + event.stopPropagation() + vscode.postMessage({ type: "openMention", text: mentionValue }) + }, + }, + children: [{ type: "text", value: mentionText }], + }) + + lastIndex = mentionStart + match[0].length + } + + // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent: when the mention ends at the text end the tail slice is empty, and pushing an empty text node renders nothing + if (lastIndex < originalValue.length) { + children.push({ type: "text", value: originalValue.slice(lastIndex) }) + } + + parent.children.splice(index, 1, ...children) + }) + } +} + +/** + * Rehype plugin that drops the lone "\n" text node mdast-util-to-hast emits + * right after every
(its hardBreak handler returns [
, "\n"]). + * + * The paragraph styling in this webview uses `white-space: pre-wrap`, where a + * literal newline is significant. Without this, every remark-breaks
would + * be followed by an extra pre-wrap line break, inserting a blank line between + * each soft-broken line. Removing the node leaves exactly one line break per + * soft break, independent of CSS white-space handling. + */ +function rehypeStripBreakNewlines() { + return (tree: any) => { + // Stryker disable next-line StringLiteral: equivalent (empirically verified): an empty node-type test degenerates to visit-all and the body's tagName guard no-ops on non-elements, so behavior is unchanged + visit(tree, "element", (node: any, index: number | undefined, parent: any) => { + // Stryker disable next-line ConditionalExpression,LogicalOperator,BlockStatement: equivalent: hast elements always have a tagName and unist-util-visit always provides index/parent for non-root nodes; mdast-util-to-hast emits a lone newline text node only after a br element + if (node.tagName !== "br" || index === undefined || !parent) { + return + } + const next = parent.children[index + 1] + // Stryker disable next-line ConditionalExpression,LogicalOperator,OptionalChaining: equivalent: the hardBreak handler always emits a br followed by a newline text node, so the sibling right after a br is always exactly that text node and the checks restate the invariant + if (next?.type === "text" && next.value === "\n") { + parent.children.splice(index + 1, 1) + } + }) + } +} + // Codicon glyphs used as the leading icon for each GitHub-style alert type. const ALERT_ICONS: Record = { note: "codicon-info", @@ -32,6 +293,22 @@ const ALERT_LABELS: Record = { interface MarkdownBlockProps { markdown?: string + /** + * Render context mentions (@/path, @problems, @terminal, ...) as clickable + * spans that post `openMention`. Off by default: mentions are only + * actionable where the text is user-authored (the expanded task prompt). + * Assistant-generated content (messages, reasoning, tool output, todos) + * keeps mention patterns as inert text. + */ + mentions?: boolean + /** + * Render single newlines as
(remark-breaks) instead of collapsing them + * to spaces per CommonMark. Off by default so the shared pipeline keeps its + * CommonMark soft-break behavior for assistant-generated content. The + * expanded task prompt (user-authored text) enables it so plain multi-line + * prompts keep their line breaks while markdown still parses. + */ + breaks?: boolean } const StyledMarkdown = styled.div` @@ -273,7 +550,7 @@ const StyledMarkdown = styled.div` } ` -const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { +const MarkdownBlock = memo(({ markdown, mentions = false, breaks = false }: MarkdownBlockProps) => { const components = useMemo( () => ({ table: ({ children, ...props }: any) => { @@ -305,6 +582,14 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { values = { line: parseInt(match[2]) } } + // Reject path traversal: task markdown is untrusted, so a + // `..` segment (e.g. `[x](../../.env)`) must never reach the + // extension's openFile. The extension re-checks workspace + // containment as a second line of defense. + if (filePath.split(/[\\/]/).includes("..")) { + return + } + // Add ./ prefix if needed if (!filePath.startsWith("/") && !filePath.startsWith("./")) { filePath = "./" + filePath @@ -313,7 +598,13 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { vscode.postMessage({ type: "openFile", text: filePath, - values, + values: { + ...(values ?? {}), + // Tag the request as markdown-sourced so the extension can + // apply strict workspace containment: markdown links are + // untrusted input. + fromMarkdown: true, + }, }) } @@ -394,6 +685,14 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { [], ) + // When mentions are actionable, rewrite the raw markdown before parsing so + // mention matching runs on the untokenized string (see prepareMentions). + const { preparedMarkdown, mentions: mentionList } = useMemo( + // Stryker disable next-line ArrayDeclaration: equivalent: this mentions list is consumed only by the rehypeMentions plugin, which is registered only when the mentions prop is truthy, so the junk element is never read + () => (mentions ? prepareMentions(markdown || "") : { preparedMarkdown: markdown || "", mentions: [] }), + [markdown, mentions], + ) + return ( { [remarkGfm, { singleTilde: false }], remarkMath, remarkGithubAlerts, + ...(breaks ? [remarkBreaks] : []), () => { return (tree: any) => { visit(tree, "code", (node: any) => { @@ -415,9 +715,13 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => { } }, ]} - rehypePlugins={[rehypeKatex as any]} + rehypePlugins={[ + ...(mentions ? [[rehypeMentions, mentionList] as const] : []), + ...(breaks ? [rehypeStripBreakNewlines] : []), + rehypeKatex as any, + ]} components={components}> - {markdown || ""} + {preparedMarkdown} ) diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index 2c56fc418a..04bbd62ca3 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -1,13 +1,21 @@ -import { render, screen } from "@/utils/test-utils" +import { render, screen, fireEvent } from "@/utils/test-utils" import MarkdownBlock from "../MarkdownBlock" +const { mockPostMessage } = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), +})) + vi.mock("@src/utils/vscode", () => ({ vscode: { - postMessage: vi.fn(), + postMessage: mockPostMessage, }, })) +beforeEach(() => { + mockPostMessage.mockClear() +}) + vi.mock("@src/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ theme: "dark", @@ -217,4 +225,553 @@ describe("MarkdownBlock", () => { expect(screen.getByText("Third level ordered")).toBeInTheDocument() expect(screen.getByText("Back to first level")).toBeInTheDocument() }) + + describe("Context mentions (#559)", () => { + it("keeps mention patterns inert when the mentions prop is not set", async () => { + // Mentions are only actionable where text is user-authored. Assistant + // content rendered through the default MarkdownBlock must keep mention + // patterns as plain, non-interactive text. + const markdown = "Check @/src/file.ts and @problems." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.querySelector("p")?.textContent).toBe("Check @/src/file.ts and @problems.") + }) + + it("renders @/path/file.ts as a clickable mention span", async () => { + const markdown = "Check out @/src/components/chat/TaskHeader.tsx for details." + const { container } = render() + + await screen.findByText(/Check out/, { exact: false }) + + // The mention should be wrapped in a span with the mention-context-highlight class. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/components/chat/TaskHeader.tsx") + + // The trailing period must remain outside the mention span. + expect(container.querySelector("p")?.textContent).toBe( + "Check out @/src/components/chat/TaskHeader.tsx for details.", + ) + }) + + it("renders @problems as a clickable mention span", async () => { + const markdown = "Review the issues listed in @problems before proceeding." + const { container } = render() + + await screen.findByText(/Review/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + }) + + it("renders @terminal as a clickable mention span", async () => { + const markdown = "See the output captured in @terminal." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@terminal") + }) + + it("renders multiple mentions in the same paragraph", async () => { + const markdown = "Check @/src/file.ts and @problems, then review @terminal." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(3) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + expect(mentions[2].textContent).toBe("@terminal") + }) + + it("posts openMention message when a mention span is clicked", async () => { + const markdown = "See @/src/components/chat/TaskHeader.tsx." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mentionSpan = container.querySelector("span.mention-context-highlight")! + fireEvent.click(mentionSpan) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openMention", + text: "/src/components/chat/TaskHeader.tsx", + }) + }) + + it("does not match @ in the middle of a word or log entry", async () => { + const markdown = "Error: Failed@localhost/status code 404." + const { container } = render() + + await screen.findByText(/Error/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(0) + }) + + it("keeps mention patterns literal inside fenced code blocks", async () => { + const markdown = "```bash\necho hello @problems\n```" + const { container } = render() + + await screen.findByText(/echo/, { exact: false }) + + // Code is literal content: the mention must stay plain text, not become a + // clickable span (which would also make the text vanish from CodeBlock). + expect(container.querySelector("code")?.textContent).toBe("echo hello @problems\n") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + // No placeholder control characters leak out of the masked code region. + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps mention patterns literal inside inline code", async () => { + const markdown = "Use `@problems` carefully." + const { container } = render() + + await screen.findByText(/Use/, { exact: false }) + + expect(container.querySelector("code")?.textContent).toBe("@problems") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + // No placeholder control characters leak out of the masked code region. + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps mention patterns literal inside link text even when enabled", async () => { + // A mention inside
must not become a nested role=button span: that + // is invalid interactive content (WHATWG) and would block the anchor's + // own openFile handler via stopPropagation. + const markdown = "see [open @/src/main.ts](/src/main.ts) please" + const { container } = render() + + await screen.findByText(/please/, { exact: false }) + + const anchor = container.querySelector("a")! + expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0) + expect(anchor.textContent).toBe("open @/src/main.ts") + + // The anchor's own handler still fires (nothing swallows the click). + fireEvent.click(anchor) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "/src/main.ts", + values: { fromMarkdown: true }, + }) + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openMention" })) + }) + + it("keeps reference link destinations inert and preserves the original href", async () => { + // A reference definition's destination renders as the reference link's + // href, so rewriting it to a mention placeholder would corrupt the href + // (control characters instead of the original path) rather than produce + // a mention span. The whole definition region must stay masked. + const markdown = "[docs]: @/docs/readme.md\n\nSee [docs] and @problems." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + // The reference link keeps its original href, untouched by mention + // preprocessing, and the destination never becomes a mention span. + const anchor = container.querySelector("a")! + expect(anchor).toHaveAttribute("href", "@/docs/readme.md") + expect(anchor.textContent).toBe("docs") + expect(container.querySelectorAll("a span.mention-context-highlight").length).toBe(0) + + // Masking the definition must not affect a real mention in the body. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + + // No placeholder control characters leak into the rendered output. + expect(container.textContent).not.toContain("\u0001") + expect(anchor.getAttribute("href")).not.toContain("\u0001") + }) + + it("keeps reference link labels inert and preserves the label text", async () => { + // A mention inside a reference link's label renders as the anchor's + // text. Rewriting it to a mention placeholder would leak the raw + // placeholder (rehypeMentions skips anchors, so the control characters + // would render verbatim inside the link) and a role=button span inside + // would be invalid nested interactive content. The whole reference + // region must stay masked. + const markdown = "See [the @problems summary][docs] now.\n\n[docs]: https://example.com/problems" + const { container } = render() + + await screen.findByText(/now/, { exact: false }) + + const anchor = container.querySelector("a")! + expect(anchor).toHaveAttribute("href", "https://example.com/problems") + expect(anchor.textContent).toBe("the @problems summary") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + + // No placeholder control characters leak into the rendered output. + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps image reference alt text inert and preserves the alt attribute", async () => { + // An image reference's alt renders as the img's alt attribute. Rewriting + // it to a mention placeholder would corrupt the alt instead of producing + // a mention span. The whole reference region must stay masked. + const markdown = "See ![a @problems screenshot][docs] now.\n\n[docs]: https://example.com/problems.png" + const { container } = render() + + await screen.findByText(/now/, { exact: false }) + + const img = container.querySelector("img")! + expect(img).toHaveAttribute("src", "https://example.com/problems.png") + expect(img).toHaveAttribute("alt", "a @problems screenshot") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps mention patterns inert inside image alt text", async () => { + // An image's alt renders as the img's alt attribute, not as body text. A + // mention inside it must stay literal: rewriting it to a mention placeholder + // would corrupt the alt with control characters. + const markdown = "See ![a @problems screenshot](https://example.com/problems.png) now." + const { container } = render() + + await screen.findByText(/now/, { exact: false }) + + const img = container.querySelector("img")! + expect(img).toHaveAttribute("src", "https://example.com/problems.png") + expect(img).toHaveAttribute("alt", "a @problems screenshot") + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps mention patterns inert inside inline and block math", async () => { + // Math regions render through KaTeX from the formula value, not as plain + // text. A mention rewritten to a placeholder would leak control characters + // into the rendered formula (MathML annotation included), so both inline + // and display math must stay masked. + const markdown = "Inline $x = @problems$ and block math:\n\n$$\n@problems + 1\n$$\n\ndone" + const { container } = render() + + await screen.findByText(/done/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps mention patterns inert inside raw HTML blocks", async () => { + // Raw HTML blocks render as escaped literal text (this stack has no + // rehype-raw). A mention inside must stay verbatim: rewriting it to a + // mention placeholder would leak control characters into the output. + const markdown = "before\n\n
\n@problems\n
\n\nafter" + const { container } = render() + + await screen.findByText(/after/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.textContent).toContain("@problems") + expect(container.textContent).not.toContain("\u0001") + }) + + it("keeps the shared regex boundary rules when a mention directly follows a link or inline code", async () => { + // Matching must run on the raw string so the shared regex's start boundary + // sees the real characters: with no whitespace after a closing `)` or a + // backtick, `@problems` is not a mention (the collapsed + // component rejects it too). Replacing the literal regions with spaces + // before matching would make them actionable. + const markdown = "[file](/src/a.ts)@problems and `x`@problems" + const { container } = render() + + // The anchor text is a stable, unique wait target. + await screen.findByText("file") + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + // The text still renders verbatim (link + plain text, no spans). + expect(container.querySelector("p")?.textContent).toBe("file@problems and x@problems") + }) + + it("keeps a whitespace-separated mention after a link or inline code actionable", async () => { + // The space is a legitimate boundary for the shared regex, so these + // mentions stay clickable: the masked regions end before the spaces and + // the match ranges do not intersect them. + const markdown = "[file](/src/a.ts) @problems and `x` @problems" + const { container } = render() + + // The anchor text is a stable, unique wait target. + await screen.findByText("file") + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(2) + expect(mentions[0].textContent).toBe("@problems") + expect(mentions[1].textContent).toBe("@problems") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "problems" }) + }) + + it("renders a standalone mention with no surrounding text", async () => { + // A mention that both starts and ends the text node exercises the + // no-leading-text and no-trailing-text branches of the splitter. + const markdown = "@problems" + const { container } = render() + + await screen.findByText("@problems") + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + + // No leading/trailing text: the paragraph is exactly the mention. + expect(container.querySelector("p")?.textContent).toBe("@problems") + }) + + it("makes mentions keyboard operable (role=button, tabIndex, Enter/Space)", async () => { + const markdown = "See @terminal." + const { container } = render() + + await screen.findByText(/See/, { exact: false }) + + const mention = container.querySelector("span.mention-context-highlight")! + expect(mention.getAttribute("role")).toBe("button") + expect(mention.getAttribute("tabindex")).toBe("0") + expect(mention.classList.contains("text-[0.9em]")).toBe(true) + expect(mention.classList.contains("cursor-pointer")).toBe(true) + + // Enter/Space must both post and be default-prevented: dispatching a + // cancelable event returns false once preventDefault has run, and Space's + // default action would otherwise scroll the expanded task panel while a + // mention has focus. + expect(fireEvent.keyDown(mention, { key: "Enter" })).toBe(false) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) + + mockPostMessage.mockClear() + expect(fireEvent.keyDown(mention, { key: " " })).toBe(false) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "terminal" }) + + mockPostMessage.mockClear() + // An unrelated key neither posts nor prevents the default action. + expect(fireEvent.keyDown(mention, { key: "a" })).toBe(true) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("preserves regular text around mentions", async () => { + const markdown = "Before @problems middle after" + const { container } = render() + + await screen.findByText(/Before/, { exact: false }) + + const paragraph = container.querySelector("p") + expect(paragraph?.textContent).toBe("Before @problems middle after") + + // The mention span should only contain the mention itself. + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@problems") + }) + + it("matches the full mention path when it contains markdown-active characters", async () => { + // remark tokenizes `@/src/__init__.py` as `@/src/` + init + `.py`, + // so matching on tokenized text nodes would truncate the mention to `@/src/` and + // post the wrong path. Mention matching must run on the raw string instead. + const markdown = "Run the tests for @/src/__init__.py now." + const { container } = render() + + await screen.findByText(/Run the tests/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/__init__.py") + + // No stray for the `__init__` part: the whole path is one mention. + expect(container.querySelector("p")?.querySelector("strong")).toBeNull() + + // Clicking must post the FULL path, not the truncated `@/src/` prefix. + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/__init__.py" }) + }) + + it("matches mentions containing asterisks on the raw string", async () => { + // `*files*` would tokenize as emphasis, splitting the path across text nodes. + const markdown = "Check @/src/*files* before shipping." + const { container } = render() + + await screen.findByText(/Check/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions.length).toBe(1) + expect(mentions[0].textContent).toBe("@/src/*files*") + + fireEvent.click(mentions[0]) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "/src/*files*" }) + }) + + it("resolves mentions and line breaks together in the same prompt", async () => { + // The raw-string mention preprocessing and remark-breaks both rewrite the + // paragraph; they must compose: the mention stays a single span and the + // soft break between the lines renders as one
. + const markdown = "Check @/src/file.ts\nthen review @problems" + const { container } = render() + + await screen.findByText(/then review/, { exact: false }) + + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(2) + expect(mentions[0].textContent).toBe("@/src/file.ts") + expect(mentions[1].textContent).toBe("@problems") + + const paragraph = container.querySelector("p") + expect(paragraph?.querySelectorAll("br")).toHaveLength(1) + expect(paragraph?.textContent).toBe("Check @/src/file.tsthen review @problems") + }) + + it("keeps placeholder-free output when the prompt contains no mentions", async () => { + // Preprocessing must not leak placeholder control characters into rendered + // text when the (raw) text happens to contain mention-like patterns that do + // not match (e.g. @ not preceded by whitespace). + const markdown = "Failed@localhost/status code 404." + const { container } = render() + + await screen.findByText(/Failed/, { exact: false }) + + expect(container.querySelectorAll("span.mention-context-highlight").length).toBe(0) + expect(container.querySelector("p")?.textContent).toBe("Failed@localhost/status code 404.") + }) + }) + + describe("line breaks (breaks prop)", () => { + it("renders a soft line break as
when breaks is set", async () => { + const markdown = "line one\nline two" + const { container } = render() + + await screen.findByText(/line one/) + + const paragraph = container.querySelector("p") + expect(paragraph).not.toBeNull() + // remark-breaks turns the single newline into a real
so the line + // break is structural instead of relying on CSS white-space. + expect(paragraph?.querySelector("br")).not.toBeNull() + expect(paragraph?.textContent).toBe("line oneline two") + }) + + it("keeps soft line breaks as text by default", async () => { + const markdown = "line one\nline two" + const { container } = render() + + // The text matcher must be inexact: by default the newline stays inside + // the single text node, so "line one" is not a standalone node. + await screen.findByText(/line one/, { exact: false }) + + const paragraph = container.querySelector("p") + expect(paragraph?.querySelector("br")).toBeNull() + expect(paragraph?.textContent).toBe("line one\nline two") + }) + + it("keeps blank lines as paragraph breaks when breaks is set", async () => { + const markdown = "first paragraph\n\nsecond paragraph" + const { container } = render() + + await screen.findByText("first paragraph") + + // Two separate paragraphs (the blank line is a hard break, not a soft one). + expect(container.querySelectorAll("p")).toHaveLength(2) + expect(container.querySelector("p")?.querySelector("br")).toBeNull() + }) + }) + + describe("file anchor validation", () => { + it("does not post openFile for traversal targets and still prevents navigation", async () => { + // Task markdown is untrusted: a `..` segment (e.g. `[x](../../.env)`) must + // never reach the extension's openFile handler, and the webview must not + // navigate to a bogus relative URL either. + const markdown = "open [the secret](../../.env) now" + const { container } = render() + + await screen.findByText(/the secret/) + + const anchor = container.querySelector("a")! + // The click is still swallowed (preventDefault ran) but nothing is posted. + expect(fireEvent.click(anchor)).toBe(false) + expect(mockPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "openFile" })) + }) + + it("leaves non-file links to the browser without posting openFile", async () => { + // External schemes are not local files: the anchor keeps its default + // navigation behavior (no preventDefault) and posts nothing. + const markdown = "visit https://example.com/docs today" + const { container } = render() + + await screen.findByText(/today/) + + const anchor = container.querySelector("a")! + expect(fireEvent.click(anchor)).toBe(true) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("posts openFile with a ./ prefix for plain relative paths", async () => { + const markdown = "see [main.ts](src/main.ts) for the entry point" + const { container } = render() + + await screen.findByText(/entry point/) + + const anchor = container.querySelector("a")! + fireEvent.click(anchor) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/main.ts", + values: { fromMarkdown: true }, + }) + }) + + it("posts openFile with a line number for links with a line anchor", async () => { + // The line anchor must reach the extension: the posted values keep both + // the parsed line and the fromMarkdown tag (a `values && {}` mutation + // would drop the line field). + const markdown = "see [line 12](src/main.ts:12) for context" + const { container } = render() + + await screen.findByText(/context/) + + const anchor = container.querySelector("a")! + fireEvent.click(anchor) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/main.ts", + values: { line: 12, fromMarkdown: true }, + }) + }) + }) + + describe("empty markdown", () => { + it("renders nothing for empty markdown with mentions enabled", () => { + // The falsy-markdown fast path must produce the same empty output as + // parsing an empty string: no paragraph and no leaked placeholder. + const { container } = render() + + expect(container.querySelector("p")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("renders nothing for empty markdown without mentions", () => { + const { container } = render() + + expect(container.querySelector("p")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("recomputes the prepared markdown when the markdown prop changes", async () => { + // The prepared-markdown memo must track its inputs: a stale cache would + // keep rendering the first prompt after the text changes. + const { container, rerender } = render() + + await screen.findByText(/first/) + expect(container.querySelectorAll("span.mention-context-highlight")).toHaveLength(1) + + rerender() + await screen.findByText(/second/) + const mentions = container.querySelectorAll("span.mention-context-highlight") + expect(mentions).toHaveLength(1) + expect(mentions[0].textContent).toBe("@terminal") + }) + }) })