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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Fixed long chat sessions freezing the VS Code renderer by removing selection-change snapshot floods, coalescing Host updates, sending revisioned transcript splices instead of the full history for every ACP chunk, bounding the rendered transcript window, and persisting only lightweight Webview UI state.
- Fixed Windows ACP startup for npm-installed Reasonix CLIs by preferring the packaged native executable, resolving `.cmd` and explicitly configured extensionless shims, and safely launching custom command wrappers when no native binary is available.
- Updated the chat Webview to follow VS Code theme colors across light and dark themes instead of mixing fixed dark surfaces and highlights into the active theme.
- Prevented malformed or extended Markdown fences from entering a non-progress render loop and freezing the Webview.

## 0.3.0

Expand Down
2 changes: 1 addition & 1 deletion esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const builds = [
},
{
...common,
entryPoints: ["test/acpProtocol.test.ts", "test/attachments.test.ts", "test/jsonRpc.test.ts", "test/chatState.test.ts", "test/webviewProtocol.test.ts", "test/sanitize.test.ts", "test/keyboard.test.ts", "test/resourceMentions.test.ts", "test/resourceSuggestions.test.ts", "test/slashCommands.test.ts", "test/composerSuggestions.test.ts", "test/snapshotSync.test.ts", "test/reasonixLauncher.test.ts", "test/themeStyles.test.ts"],
entryPoints: ["test/acpProtocol.test.ts", "test/attachments.test.ts", "test/jsonRpc.test.ts", "test/chatState.test.ts", "test/webviewProtocol.test.ts", "test/sanitize.test.ts", "test/keyboard.test.ts", "test/resourceMentions.test.ts", "test/resourceSuggestions.test.ts", "test/slashCommands.test.ts", "test/composerSuggestions.test.ts", "test/snapshotSync.test.ts", "test/reasonixLauncher.test.ts", "test/themeStyles.test.ts", "test/markdown.test.ts"],
outdir: "dist/test",
platform: "node",
format: "cjs",
Expand Down
109 changes: 109 additions & 0 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
export type MarkdownBlock =
| { kind: "code"; language: string; text: string }
| { kind: "unorderedList"; items: string[] }
| { kind: "orderedList"; items: string[] }
| { kind: "heading"; level: 1 | 2 | 3; text: string }
| { kind: "paragraph"; text: string };

type Fence = {
marker: "`" | "~";
length: number;
language: string;
};

export function parseMarkdownBlocks(text: string): MarkdownBlock[] {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;

while (index < lines.length) {
const line = lines[index] ?? "";
if (line.trim() === "") {
index += 1;
continue;
}

const fence = openingFence(line);
if (fence) {
const code: string[] = [];
index += 1;
while (index < lines.length && !isClosingFence(lines[index] ?? "", fence)) {
code.push(lines[index] ?? "");
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ kind: "code", language: fence.language, text: code.join("\n") });
continue;
}

if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index] ?? "")) {
items.push((lines[index] ?? "").replace(/^\s*[-*]\s+/, ""));
index += 1;
}
blocks.push({ kind: "unorderedList", items });
continue;
}

if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index] ?? "")) {
items.push((lines[index] ?? "").replace(/^\s*\d+\.\s+/, ""));
index += 1;
}
blocks.push({ kind: "orderedList", items });
continue;
}

const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
blocks.push({
kind: "heading",
level: heading[1]?.length as 1 | 2 | 3,
text: heading[2] ?? "",
});
index += 1;
continue;
}

const paragraph: string[] = [];
do {
paragraph.push(lines[index] ?? "");
index += 1;
} while (index < lines.length && !startsBlock(lines[index] ?? ""));
blocks.push({ kind: "paragraph", text: paragraph.join("\n") });
}

return blocks;
}

function startsBlock(line: string): boolean {
return line.trim() === ""
|| openingFence(line) !== undefined
|| /^\s*[-*]\s+/.test(line)
|| /^\s*\d+\.\s+/.test(line)
|| /^(#{1,3})\s+/.test(line);
}

function openingFence(line: string): Fence | undefined {
const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
if (!match) {
return undefined;
}
const run = match[1] ?? "";
const info = (match[2] ?? "").trim();
return {
marker: run[0] as "`" | "~",
length: run.length,
language: info.split(/\s+/, 1)[0] ?? "",
};
}

function isClosingFence(line: string, fence: Fence): boolean {
const match = line.match(/^\s{0,3}(`{3,}|~{3,})\s*$/);
const run = match?.[1] ?? "";
return run.startsWith(fence.marker) && run.length >= fence.length;
}
79 changes: 17 additions & 62 deletions src/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MAX_ATTACHMENTS, type PendingAttachment } from "./attachments";
import type { ChatItem } from "./chatState";
import { getComposerTrigger, replaceComposerTrigger, slashSuggestions, type ComposerTrigger } from "./composerSuggestions";
import { shouldSubmitPromptOnKeydown } from "./keyboard";
import { parseMarkdownBlocks } from "./markdown";
import type { ResourceSuggestion } from "./resourceSuggestions";
import { applyTranscriptSplice, transcriptWindowStart, type TranscriptSplice } from "./snapshotSync";
import type { HostToWebviewMessage } from "./webviewProtocol";
Expand Down Expand Up @@ -2354,80 +2355,34 @@ function detailsBlock(summaryText: string, text: string): HTMLElement {
function renderMarkdown(text: string): HTMLElement {
const root = document.createElement("div");
root.className = "markdown";
const lines = text.replace(/\r\n/g, "\n").split("\n");
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? "";
if (line.trim() === "") {
i += 1;
continue;
}

const fence = line.match(/^```([\w.+-]*)\s*$/);
if (fence) {
const language = fence[1] ?? "";
const code: string[] = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) {
code.push(lines[i] ?? "");
i += 1;
}
if (i < lines.length) {
i += 1;
}
root.append(codeBlock(code.join("\n"), language));
continue;
}

if (/^\s*[-*]\s+/.test(line)) {
for (const block of parseMarkdownBlocks(text)) {
if (block.kind === "code") {
root.append(codeBlock(block.text, block.language));
} else if (block.kind === "unorderedList") {
const list = document.createElement("ul");
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i] ?? "")) {
for (const text of block.items) {
const item = document.createElement("li");
appendInline(item, (lines[i] ?? "").replace(/^\s*[-*]\s+/, ""));
appendInline(item, text);
list.append(item);
i += 1;
}
root.append(list);
continue;
}

if (/^\s*\d+\.\s+/.test(line)) {
} else if (block.kind === "orderedList") {
const list = document.createElement("ol");
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? "")) {
for (const text of block.items) {
const item = document.createElement("li");
appendInline(item, (lines[i] ?? "").replace(/^\s*\d+\.\s+/, ""));
appendInline(item, text);
list.append(item);
i += 1;
}
root.append(list);
continue;
}

const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1]?.length ?? 2;
const h = document.createElement(`h${level + 2}`) as HTMLHeadingElement;
appendInline(h, heading[2] ?? "");
} else if (block.kind === "heading") {
const h = document.createElement(`h${block.level + 2}`) as HTMLHeadingElement;
appendInline(h, block.text);
root.append(h);
i += 1;
continue;
}

const paragraph: string[] = [];
while (
i < lines.length &&
(lines[i] ?? "").trim() !== "" &&
!/^```/.test(lines[i] ?? "") &&
!/^\s*[-*]\s+/.test(lines[i] ?? "") &&
!/^\s*\d+\.\s+/.test(lines[i] ?? "") &&
!/^(#{1,3})\s+/.test(lines[i] ?? "")
) {
paragraph.push(lines[i] ?? "");
i += 1;
} else {
const p = document.createElement("p");
appendInline(p, block.text);
root.append(p);
}
const p = document.createElement("p");
appendInline(p, paragraph.join("\n"));
root.append(p);
}
return root;
}
Expand Down
41 changes: 41 additions & 0 deletions test/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseMarkdownBlocks } from "../src/markdown";

test("parseMarkdownBlocks accepts common and extended fence info without stalling", () => {
assert.deepEqual(parseMarkdownBlocks("```c#\nConsole.WriteLine();\n```"), [
{ kind: "code", language: "c#", text: "Console.WriteLine();" },
]);
assert.deepEqual(parseMarkdownBlocks("````python title=demo\nprint('ok')\n````"), [
{ kind: "code", language: "python", text: "print('ok')" },
]);
assert.deepEqual(parseMarkdownBlocks("~~~{.matlab}\ndisp('ok')\n~~~"), [
{ kind: "code", language: "{.matlab}", text: "disp('ok')" },
]);
});

test("parseMarkdownBlocks consumes unclosed and unusual fences to completion", () => {
assert.deepEqual(parseMarkdownBlocks("```python title=demo\nprint('ok')"), [
{ kind: "code", language: "python", text: "print('ok')" },
]);
assert.deepEqual(parseMarkdownBlocks("````\nraw\n```\nstill raw"), [
{ kind: "code", language: "", text: "raw\n```\nstill raw" },
]);
});

test("parseMarkdownBlocks preserves the supported block structure", () => {
assert.deepEqual(parseMarkdownBlocks("# Heading\n\n- one\n- two\n\n1. first\n\nparagraph\ncontinued"), [
{ kind: "heading", level: 1, text: "Heading" },
{ kind: "unorderedList", items: ["one", "two"] },
{ kind: "orderedList", items: ["first"] },
{ kind: "paragraph", text: "paragraph\ncontinued" },
]);
});

test("parseMarkdownBlocks handles a large transcript payload in one pass", () => {
const input = Array.from({ length: 10_000 }, (_, index) => `line ${index}`).join("\n");
const blocks = parseMarkdownBlocks(input);
assert.equal(blocks.length, 1);
assert.equal(blocks[0]?.kind, "paragraph");
assert.equal(blocks[0]?.kind === "paragraph" ? blocks[0].text.length : 0, input.length);
});
25 changes: 25 additions & 0 deletions test/vscode/fake-acp.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,31 @@ function handlePrompt(message) {
log({ method: "process/disconnect-probe" });
process.exit(23);
}
if (text.includes("long_webview_probe")) {
const payload = "x".repeat(1_560);
for (let index = 0; index < 205; index += 1) {
notify("session/update", {
sessionId,
update: {
sessionUpdate: index % 2 === 0 ? "agent_message_chunk" : "agent_thought_chunk",
content: { type: "text", text: `history-${index} ${payload}` },
},
});
}
notify("session/update", {
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text: "```c# title=demo\nlong-history-tail\n```" },
},
});
log({ method: "webview/long-history-sent", itemCount: 206 });
result(message.id, { stopReason: "end_turn" });
return;
}
if (text.includes("post_history_probe")) {
log({ method: "webview/post-history-prompt" });
}
if (text.includes("permission_probe")) {
const toolCallId = "fake-permission-tool";
notify("session/update", { sessionId, update: { sessionUpdate: "tool_call", toolCallId, title: "write_file", kind: "edit", status: "pending", rawInput: { path: "sample.ts" }, locations: [{ path: message.params?.cwd || "sample.ts", line: 1 }] } });
Expand Down
18 changes: 16 additions & 2 deletions test/vscode/runTest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { runTests } from "@vscode/test-electron";
import { downloadAndUnzipVSCode, runTests } from "@vscode/test-electron";

async function main(): Promise<void> {
const extensionDevelopmentPath = path.resolve(__dirname, "../../..");
Expand All @@ -18,8 +18,9 @@ async function main(): Promise<void> {
fs.writeFileSync(path.join(workspacePath, "src", "helper.ts"), "export const helper = true;\n");
writeFakeAcpWrapper(fakeAcp, fakeAcpScript);

const testExecutable = vscodeExecutablePath ?? await downloadAndUnzipVSCode("stable");
await runTests({
...(vscodeExecutablePath ? { vscodeExecutablePath } : {}),
vscodeExecutablePath: existingVSCodeExecutable(testExecutable),
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [workspacePath, "--user-data-dir", userDataDir, "--disable-extensions", "--disable-workspace-trust"],
Expand All @@ -34,6 +35,19 @@ async function main(): Promise<void> {
});
}

function existingVSCodeExecutable(downloadedExecutable: string): string {
if (fs.existsSync(downloadedExecutable)) {
return downloadedExecutable;
}
if (process.platform === "darwin" && downloadedExecutable.endsWith("/MacOS/Electron")) {
const renamedExecutable = downloadedExecutable.slice(0, -"Electron".length) + "Code";
if (fs.existsSync(renamedExecutable)) {
return renamedExecutable;
}
}
return downloadedExecutable;
}

function writeFakeAcpWrapper(target: string, script: string): void {
if (process.platform === "win32") {
fs.writeFileSync(target, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`);
Expand Down
13 changes: 13 additions & 0 deletions test/vscode/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ export async function run(): Promise<void> {
await waitForLog(fakeLog, "session/prompt/cancelled");
await slowTurn;

await vscode.commands.executeCommand("reasonix.test.webviewMessage", {
command: "sendPrompt",
text: "long_webview_probe",
});
await waitForLog(fakeLog, "webview/long-history-sent");
await waitForSnapshot((state) => state.items?.some((item: TestRecord) => item.text?.includes("long-history-tail")));
await vscode.commands.executeCommand("reasonix.openChat");
await vscode.commands.executeCommand("reasonix.test.webviewMessage", {
command: "sendPrompt",
text: "post_history_probe",
});
await waitForLog(fakeLog, "webview/post-history-prompt");

await vscode.commands.executeCommand("reasonix.test.webviewMessage", { command: "sendPrompt", text: "disconnect_probe" });
await waitForLog(fakeLog, "process/disconnect-probe");
await waitForLog(fakeLog, "session/resume");
Expand Down
Loading