Skip to content

Commit 808b73a

Browse files
committed
feat(acp): render agent markdown and open linked workspace files
1 parent 9f353e2 commit 808b73a

File tree

3 files changed

+295
-7
lines changed

3 files changed

+295
-7
lines changed

src/pages/acp/acp.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -491,9 +491,10 @@ export default function AcpPageInclude() {
491491

492492
// ─── Event Handlers ───
493493
function createTimelineElement(entry) {
494+
const cwd = client.session?.cwd || $form.getValues().cwd || "";
494495
switch (entry.type) {
495496
case "message":
496-
return ChatMessage({ message: entry.message });
497+
return ChatMessage({ message: entry.message, cwd });
497498
case "tool_call":
498499
return ToolCallCard({ toolCall: entry.toolCall });
499500
case "plan":
@@ -516,8 +517,12 @@ export default function AcpPageInclude() {
516517
}
517518

518519
entries.forEach((entry) => {
520+
const entryWithContext = {
521+
...entry,
522+
cwd: client.session?.cwd || $form.getValues().cwd || "",
523+
};
519524
if (timelineElements.has(entry.entryId)) {
520-
timelineElements.get(entry.entryId).update(entry);
525+
timelineElements.get(entry.entryId).update(entryWithContext);
521526
} else {
522527
const $entry = createTimelineElement(entry);
523528
if (!$entry) return;

src/pages/acp/acp.scss

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,14 +496,71 @@
496496
line-height: 1.6;
497497
word-break: break-word;
498498
overflow-wrap: anywhere;
499-
white-space: pre-wrap;
499+
white-space: normal;
500500
min-width: 0;
501501
overflow: hidden;
502502
user-select: text;
503503
-webkit-user-select: text;
504504
box-shadow: 0 1px 3px
505505
color-mix(in srgb, var(--box-shadow-color), transparent 50%);
506506

507+
.acp-message-text {
508+
white-space: pre-wrap;
509+
}
510+
511+
.acp-markdown-block {
512+
min-width: 0;
513+
color: inherit;
514+
}
515+
516+
.acp-markdown-block:empty {
517+
display: none;
518+
}
519+
520+
.acp-markdown-block > :first-child {
521+
margin-top: 0;
522+
}
523+
524+
.acp-markdown-block > :last-child {
525+
margin-bottom: 0;
526+
}
527+
528+
.acp-markdown-block.md {
529+
font-size: inherit;
530+
line-height: inherit;
531+
532+
a {
533+
color: var(--link-text-color, var(--active-color));
534+
}
535+
536+
blockquote {
537+
background: color-mix(
538+
in srgb,
539+
var(--secondary-text-color),
540+
transparent 94%
541+
);
542+
border-radius: 0 8px 8px 0;
543+
}
544+
545+
table {
546+
font-size: 0.84em;
547+
}
548+
}
549+
550+
img,
551+
.acp-inline-image {
552+
display: block;
553+
max-width: 100%;
554+
border-radius: 8px;
555+
margin: 8px 0;
556+
}
557+
558+
.acp-resource-link {
559+
display: inline-flex;
560+
align-items: center;
561+
gap: 6px;
562+
}
563+
507564
strong {
508565
font-weight: 600;
509566
}

src/pages/acp/components/chatMessage.js

Lines changed: 230 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,249 @@
1-
export default function ChatMessage({ message }) {
1+
import fsOperation from "fileSystem";
2+
import toast from "components/toast";
3+
import DOMPurify from "dompurify";
4+
import openFile from "lib/openFile";
5+
import openFolder from "lib/openFolder";
6+
import markdownIt from "markdown-it";
7+
import Url from "utils/Url";
8+
9+
const markdown = markdownIt({
10+
breaks: true,
11+
html: false,
12+
linkify: true,
13+
});
14+
15+
const EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
16+
const LOCAL_FILE_PROTOCOLS = new Set(["file:"]);
17+
18+
function getTerminalPaths() {
19+
const packageName = window.BuildInfo?.packageName || "com.foxdebug.acode";
20+
const dataDir = `/data/user/0/${packageName}`;
21+
return {
22+
dataDir,
23+
alpineRoot: `${dataDir}/files/alpine`,
24+
publicDir: `${dataDir}/files/public`,
25+
};
26+
}
27+
28+
function safeDecode(value = "") {
29+
try {
30+
return decodeURIComponent(value);
31+
} catch {
32+
return value;
33+
}
34+
}
35+
36+
function renderMarkdown(text = "") {
37+
return DOMPurify.sanitize(markdown.render(text), {
38+
FORBID_TAGS: ["script", "style"],
39+
});
40+
}
41+
42+
function isExternalHref(href = "") {
43+
return EXTERNAL_PROTOCOLS.has(Url.getProtocol(href));
44+
}
45+
46+
function isLocalFileHref(href = "") {
47+
return LOCAL_FILE_PROTOCOLS.has(Url.getProtocol(href));
48+
}
49+
50+
function getCursorPosFromHash(hash = "") {
51+
const match = /^L(\d+)(?:C(\d+))?$/i.exec(hash.replace(/^#/, ""));
52+
if (!match) return null;
53+
54+
return {
55+
row: Number(match[1]),
56+
column: match[2] ? Math.max(0, Number(match[2]) - 1) : 0,
57+
};
58+
}
59+
60+
function normalizePathString(value = "") {
61+
return safeDecode(String(value || "").trim())
62+
.replace(/^<|>$/g, "")
63+
.replace(/^["']|["']$/g, "");
64+
}
65+
66+
function convertProotPath(path = "") {
67+
const normalizedPath = normalizePathString(path);
68+
if (!normalizedPath) return normalizedPath;
69+
70+
const { dataDir, alpineRoot } = getTerminalPaths();
71+
if (isLocalFileHref(normalizedPath)) {
72+
return normalizedPath;
73+
}
74+
if (normalizedPath.startsWith("/public")) {
75+
return `file://${dataDir}/files${normalizedPath}`;
76+
}
77+
if (
78+
normalizedPath.startsWith("/sdcard") ||
79+
normalizedPath.startsWith("/storage") ||
80+
normalizedPath.startsWith("/data")
81+
) {
82+
return `file://${normalizedPath}`;
83+
}
84+
if (normalizedPath.startsWith("/home/") || normalizedPath === "/home") {
85+
const suffix = normalizedPath.slice("/home".length);
86+
return `file://${alpineRoot}/home${suffix}`;
87+
}
88+
if (normalizedPath.startsWith("/")) {
89+
return `file://${alpineRoot}${normalizedPath}`;
90+
}
91+
92+
return normalizedPath;
93+
}
94+
95+
function resolveCwd(cwd = "") {
96+
const normalizedCwd = normalizePathString(cwd);
97+
if (!normalizedCwd) return "";
98+
return isLocalFileHref(normalizedCwd)
99+
? normalizedCwd
100+
: convertProotPath(normalizedCwd);
101+
}
102+
103+
function buildLocalCandidates(target = "", cwd = "") {
104+
const normalizedTarget = normalizePathString(target);
105+
const normalizedCwd = resolveCwd(cwd);
106+
if (!normalizedTarget) return [];
107+
108+
const candidates = [];
109+
const addCandidate = (value) => {
110+
const normalized = normalizePathString(value);
111+
if (!normalized || candidates.includes(normalized)) return;
112+
candidates.push(normalized);
113+
};
114+
115+
if (Url.getProtocol(normalizedTarget)) {
116+
addCandidate(normalizedTarget);
117+
return candidates;
118+
}
119+
120+
if (
121+
normalizedTarget.startsWith("/") ||
122+
normalizedTarget.startsWith("~/") ||
123+
normalizedTarget === "~"
124+
) {
125+
const homePath =
126+
normalizedTarget === "~"
127+
? "/home"
128+
: normalizedTarget.replace(/^~(?=\/)/, "/home");
129+
addCandidate(convertProotPath(homePath));
130+
addCandidate(homePath);
131+
return candidates;
132+
}
133+
134+
if (normalizedCwd) {
135+
addCandidate(Url.join(normalizedCwd, normalizedTarget));
136+
}
137+
138+
addCandidate(convertProotPath(normalizedTarget));
139+
addCandidate(normalizedTarget);
140+
141+
return candidates;
142+
}
143+
144+
function resolveLocalHref(href = "", cwd = "") {
145+
const [rawTarget, rawHash = ""] = href.split("#");
146+
const target = normalizePathString(rawTarget);
147+
if (!target) return null;
148+
149+
return {
150+
candidates: buildLocalCandidates(target, cwd),
151+
cursorPos: getCursorPosFromHash(rawHash),
152+
};
153+
}
154+
155+
async function resolveExistingPath(candidates = []) {
156+
for (const candidate of candidates) {
157+
try {
158+
const stat = await fsOperation(candidate).stat();
159+
return { url: candidate, stat };
160+
} catch {
161+
// Keep trying fallbacks until one resolves.
162+
}
163+
}
164+
165+
return null;
166+
}
167+
168+
export default function ChatMessage({ message, cwd = "" }) {
169+
let messageCwd = cwd;
170+
2171
const $content = <div className="acp-message-content"></div>;
3172
const $meta = <div className="acp-message-meta"></div>;
4173
const $role = <div className="acp-message-role"></div>;
5174

175+
$content.onclick = async (event) => {
176+
const target =
177+
event.target?.nodeType === Node.TEXT_NODE
178+
? event.target.parentElement
179+
: event.target;
180+
const $link = target?.closest?.("a[href]");
181+
if (!$link || !$content.contains($link)) return;
182+
183+
const href = ($link.getAttribute("href") || "").trim();
184+
if (!href || href === "#") return;
185+
186+
event.preventDefault();
187+
event.stopPropagation();
188+
189+
try {
190+
if (isExternalHref(href)) {
191+
system.openInBrowser(href);
192+
return;
193+
}
194+
195+
const resolved = resolveLocalHref(href, messageCwd);
196+
const match = await resolveExistingPath(resolved?.candidates || []);
197+
if (!match?.url || !match.stat) {
198+
throw new Error(`Unable to resolve path: ${href}`);
199+
}
200+
201+
if (match.stat.isDirectory) {
202+
await openFolder(match.url, {
203+
name: match.stat.name || Url.basename(match.url) || "Folder",
204+
saveState: true,
205+
listFiles: true,
206+
});
207+
return;
208+
}
209+
210+
await openFile(match.url, {
211+
render: true,
212+
cursorPos: resolved.cursorPos || undefined,
213+
});
214+
} catch (error) {
215+
console.error("[ACP] Failed to open linked resource:", error);
216+
toast(error?.message || "Failed to open linked resource");
217+
}
218+
};
219+
220+
function appendTextBlock(text) {
221+
if (message.role === "agent") {
222+
const $markdown = <div className="acp-markdown-block md"></div>;
223+
$markdown.innerHTML = renderMarkdown(text);
224+
$content.append($markdown);
225+
return;
226+
}
227+
228+
$content.append(<div className="acp-message-text">{text}</div>);
229+
}
230+
6231
function renderContent() {
7232
$content.innerHTML = "";
8233
message.content.forEach((block) => {
9234
if (block.type === "text") {
10-
$content.append(<span>{block.text}</span>);
235+
appendTextBlock(block.text);
11236
} else if (block.type === "resource_link") {
12237
$content.append(
13-
<a className="acp-resource-link" href="#">
238+
<a className="acp-resource-link" href={block.uri || "#"}>
14239
{block.name || block.uri}
15240
</a>,
16241
);
17242
} else if (block.type === "image" && block.data) {
18243
$content.append(
19244
<img
245+
className="acp-inline-image"
20246
src={`data:${block.mimeType};base64,${block.data}`}
21-
style="max-width:100%;border-radius:6px;margin:4px 0"
22247
/>,
23248
);
24249
}
@@ -49,6 +274,7 @@ export default function ChatMessage({ message }) {
49274

50275
$el.update = (msg) => {
51276
message = msg.message || msg;
277+
if (msg.cwd) messageCwd = msg.cwd;
52278
renderContent();
53279
renderMeta();
54280
};

0 commit comments

Comments
 (0)