Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@ import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants";
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
import { findTabInTree } from "@features/panels/store/panelTree";
import { getSessionService } from "@features/sessions/service/service";
import { escapeXmlAttr } from "@utils/xml";
import { useCallback } from "react";
import type { OnCommentCallback } from "../types";

function escapeXmlAttr(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

export function useReviewComment(taskId: string): OnCommentCallback {
return useCallback(
(filePath, startLine, endLine, side, comment) => {
Expand Down
10 changes: 2 additions & 8 deletions apps/code/src/renderer/features/message-editor/utils/content.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { escapeXmlAttr } from "@utils/xml";

export interface MentionChip {
type:
| "file"
Expand Down Expand Up @@ -35,14 +37,6 @@ export function contentToPlainText(content: EditorContent): string {
.join("");
}

function escapeXmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

export function contentToXml(content: EditorContent): string {
const inlineFilePaths = new Set<string>();
const parts = content.segments.map((seg) => {
Expand Down
10 changes: 8 additions & 2 deletions apps/code/src/renderer/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export function compactHomePath(text: string): string {
.replace(/\/home\/[^/\s]+/g, "~");
}

export function getFileName(filePath: string): string {
const parts = filePath.split(/[\\/]/);
return parts[parts.length - 1] || filePath;
}

export function getFileExtension(filePath: string): string {
const parts = filePath.split(".");
return parts.length > 1 ? parts[parts.length - 1] : "";
const name = getFileName(filePath);
const lastDot = name.lastIndexOf(".");
return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : "";
}
50 changes: 50 additions & 0 deletions apps/code/src/renderer/utils/promptContent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
extractPromptDisplayContent,
makeAttachmentUri,
parseAttachmentUri,
} from "./promptContent";

describe("promptContent", () => {
it("builds unique attachment URIs for same-name files", () => {
const firstUri = makeAttachmentUri("/tmp/one/README.md");
const secondUri = makeAttachmentUri("/tmp/two/README.md");

expect(firstUri).not.toBe(secondUri);
expect(parseAttachmentUri(firstUri)).toEqual({
id: firstUri,
label: "README.md",
});
expect(parseAttachmentUri(secondUri)).toEqual({
id: secondUri,
label: "README.md",
});
});

it("keeps duplicate file labels visible when attachment ids differ", () => {
const firstUri = makeAttachmentUri("/tmp/one/README.md");
const secondUri = makeAttachmentUri("/tmp/two/README.md");

const result = extractPromptDisplayContent([
{ type: "text", text: "compare both" },
{
type: "resource",
resource: { uri: firstUri, text: "first", mimeType: "text/markdown" },
},
{
type: "resource",
resource: {
uri: secondUri,
text: "second",
mimeType: "text/markdown",
},
},
]);

expect(result.text).toBe("compare both");
expect(result.attachments).toEqual([
{ id: firstUri, label: "README.md" },
{ id: secondUri, label: "README.md" },
]);
});
});
92 changes: 92 additions & 0 deletions apps/code/src/renderer/utils/promptContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import { getFileName } from "@utils/path";

export const ATTACHMENT_URI_PREFIX = "attachment://";

function hashAttachmentPath(filePath: string): string {
let hash = 2166136261;

for (let i = 0; i < filePath.length; i++) {
hash ^= filePath.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}

return (hash >>> 0).toString(36);
}

export function makeAttachmentUri(filePath: string): string {
const label = encodeURIComponent(getFileName(filePath));
const id = hashAttachmentPath(filePath);
return `${ATTACHMENT_URI_PREFIX}${id}?label=${label}`;
}

export interface AttachmentRef {
id: string;
label: string;
}

export function parseAttachmentUri(uri: string): AttachmentRef | null {
if (!uri.startsWith(ATTACHMENT_URI_PREFIX)) {
return null;
}

const rawValue = uri.slice(ATTACHMENT_URI_PREFIX.length);
const queryStart = rawValue.indexOf("?");
if (queryStart < 0) {
return null;
}

const label =
decodeURIComponent(
new URLSearchParams(rawValue.slice(queryStart + 1)).get("label") ?? "",
) || "attachment";

return { id: uri, label };
}

function getBlockAttachmentUri(block: ContentBlock): string | null {
if (block.type === "resource") {
return block.resource.uri ?? null;
}

if (block.type === "image") {
return block.uri ?? null;
}

return null;
}

export interface PromptDisplayContent {
text: string;
attachments: AttachmentRef[];
}

export function extractPromptDisplayContent(
blocks: ContentBlock[],
options?: { filterHidden?: boolean },
): PromptDisplayContent {
const filterHidden = options?.filterHidden ?? false;

const textParts: string[] = [];
for (const block of blocks) {
if (block.type !== "text") continue;
if (filterHidden) {
const meta = (block as { _meta?: { ui?: { hidden?: boolean } } })._meta;
if (meta?.ui?.hidden) continue;
}
textParts.push(block.text);
}

const seen = new Set<string>();
const attachments: AttachmentRef[] = [];
for (const block of blocks) {
const uri = getBlockAttachmentUri(block);
if (!uri || seen.has(uri)) continue;
const ref = parseAttachmentUri(uri);
if (!ref) continue;
seen.add(uri);
attachments.push(ref);
}

return { text: textParts.join(""), attachments };
}
10 changes: 2 additions & 8 deletions apps/code/src/renderer/utils/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isJsonRpcNotification,
isJsonRpcRequest,
} from "@shared/types/session-events";
import { extractPromptDisplayContent } from "@utils/promptContent";

/**
* Convert a stored log entry to an ACP message.
Expand Down Expand Up @@ -197,16 +198,9 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] {
return prompts;
}

/**
* Extract prompt text from ContentBlocks, filtering out hidden blocks.
*/
export function extractPromptText(prompt: string | ContentBlock[]): string {
if (typeof prompt === "string") return prompt;

return (prompt as ContentBlock[])
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
.join("");
return extractPromptDisplayContent(prompt).text;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions apps/code/src/renderer/utils/xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function escapeXmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

export function unescapeXmlAttr(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"clean": "node ../../scripts/rimraf.mjs dist .turbo"
},
"devDependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"tsup": "^8.5.1",
"typescript": "^5.5.0"
},
Expand Down
126 changes: 126 additions & 0 deletions packages/shared/src/cloud-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
CLOUD_PROMPT_PREFIX,
deserializeCloudPrompt,
promptBlocksToText,
serializeCloudPrompt,
} from "./cloud-prompt";

describe("cloud-prompt", () => {
describe("serializeCloudPrompt", () => {
it("returns plain text for a single text block", () => {
const result = serializeCloudPrompt([
{ type: "text", text: " hello world " },
]);
expect(result).toBe("hello world");
expect(result).not.toContain(CLOUD_PROMPT_PREFIX);
});

it("returns prefixed JSON for multi-block content", () => {
const blocks = [
{ type: "text" as const, text: "read this" },
{
type: "resource" as const,
resource: {
uri: "attachment://test.txt",
text: "file contents",
mimeType: "text/plain",
},
},
];
const result = serializeCloudPrompt(blocks);
expect(result).toMatch(
new RegExp(
`^${CLOUD_PROMPT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
),
);
const payload = JSON.parse(result.slice(CLOUD_PROMPT_PREFIX.length));
expect(payload.blocks).toEqual(blocks);
});
});

describe("deserializeCloudPrompt", () => {
it("round-trips with serializeCloudPrompt (text-only)", () => {
const original = [{ type: "text" as const, text: "hello" }];
const serialized = serializeCloudPrompt(original);
const deserialized = deserializeCloudPrompt(serialized);
expect(deserialized).toEqual(original);
});

it("round-trips with serializeCloudPrompt (multi-block)", () => {
const original = [
{ type: "text" as const, text: "read this" },
{
type: "resource" as const,
resource: {
uri: "attachment://test.txt",
text: "contents",
mimeType: "text/plain",
},
},
];
const serialized = serializeCloudPrompt(original);
const deserialized = deserializeCloudPrompt(serialized);
expect(deserialized).toEqual(original);
});

it("wraps plain string (no prefix) as a text block", () => {
const result = deserializeCloudPrompt("just a plain message");
expect(result).toEqual([{ type: "text", text: "just a plain message" }]);
});

it("returns empty array for empty string", () => {
expect(deserializeCloudPrompt("")).toEqual([]);
expect(deserializeCloudPrompt(" ")).toEqual([]);
});

it("falls back to text block for malformed JSON after prefix", () => {
const malformed = `${CLOUD_PROMPT_PREFIX}{not valid json`;
const result = deserializeCloudPrompt(malformed);
expect(result).toEqual([{ type: "text", text: malformed }]);
});

it("falls back to text block for empty blocks array", () => {
const payload = `${CLOUD_PROMPT_PREFIX}${JSON.stringify({ blocks: [] })}`;
const result = deserializeCloudPrompt(payload);
expect(result).toEqual([{ type: "text", text: payload }]);
});
});

describe("promptBlocksToText", () => {
it("extracts and joins text blocks", () => {
const result = promptBlocksToText([
{ type: "text", text: "hello " },
{
type: "resource",
resource: {
uri: "attachment://f.txt",
text: "ignored",
mimeType: "text/plain",
},
},
{ type: "text", text: "world" },
]);
expect(result).toBe("hello world");
});

it("returns empty string for non-text blocks only", () => {
expect(
promptBlocksToText([
{
type: "resource",
resource: {
uri: "attachment://f.txt",
text: "content",
mimeType: "text/plain",
},
},
]),
).toBe("");
});

it("returns empty string for empty array", () => {
expect(promptBlocksToText([])).toBe("");
});
});
});
Loading
Loading