Skip to content

Commit a23db49

Browse files
committed
Support ACP markdown file links
1 parent 7464982 commit a23db49

6 files changed

Lines changed: 137 additions & 19 deletions

File tree

anycode-react/src/Component.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { AnycodeEditor } from 'anycode-base';
44
interface AnycodeEditorProps {
55
id: string;
66
editorState: AnycodeEditor;
7-
// focus?: boolean
87
}
98

109
export default function AnycodeEditorReact({ id, editorState, }: AnycodeEditorProps) {
@@ -21,7 +20,7 @@ export default function AnycodeEditorReact({ id, editorState, }: AnycodeEditorP
2120

2221
if(focus) {
2322
let { line, column } = editorState.getCursor();
24-
if (line && column) {
23+
if (line !== undefined && column !== undefined) {
2524
editorState.requestFocus(line, column);
2625
editorState.renderCursorOrSelection();
2726
}
@@ -34,7 +33,7 @@ export default function AnycodeEditorReact({ id, editorState, }: AnycodeEditorP
3433
containerRef.current.appendChild(editorState.getContainer());
3534

3635
let { line, column } = editorState.getCursor();
37-
if (line && column) {
36+
if (line !== undefined && column !== undefined) {
3837
editorState.requestFocus(line, column);
3938
editorState.renderCursorOrSelection();
4039
}

anycode/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ const App: React.FC = () => {
435435
followEnabled={followEnabled}
436436
onToggleFollow={toggleFollowMode}
437437
onPermissionResponse={agents.sendPermissionResponse}
438+
onOpenFile={editors.openFile}
438439
/>
439440
);
440441

anycode/components/agent/AcpDialog.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ interface AcpDialogProps {
144144
onToggleDiff?: () => void;
145145
followEnabled?: boolean;
146146
onToggleFollow?: () => void;
147+
onOpenFile?: (path: string, line?: number, column?: number) => void;
147148
}
148149

149150
const AcpDialogComponent: React.FC<AcpDialogProps> = ({
@@ -180,6 +181,7 @@ const AcpDialogComponent: React.FC<AcpDialogProps> = ({
180181
onToggleDiff,
181182
followEnabled = false,
182183
onToggleFollow,
184+
onOpenFile,
183185
}) => {
184186
const [inputValue, setInputValue] = useState('');
185187
const { expanded: expandedToolCalls, toggle: toggleToolCall } = useExpandableItems();
@@ -286,6 +288,7 @@ const AcpDialogComponent: React.FC<AcpDialogProps> = ({
286288
onTogglePermission={togglePermission}
287289
onPermissionResponse={handlePermissionResponse}
288290
onUndoMessage={handleUndoMessage}
291+
onOpenFile={onOpenFile}
289292
/>
290293
</div>
291294
</div>

anycode/components/agent/AcpMessage.css

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@
107107
.acp-message-markdown .acp-inline-code {
108108
padding: 0.12em 0.35em;
109109
border-radius: 4px;
110-
background-color: var(--code-bg, #0d0d0d);
111-
color: var(--code-color, #d4d4d4);
110+
background-color: #333;
112111

113112
}
114113

anycode/components/agent/AcpMessage.tsx

Lines changed: 127 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface AcpMessageProps {
7979
onToggle?: () => void;
8080
onPermissionResponse?: (permissionId: string, optionId: string) => void;
8181
onUndo?: () => void;
82+
onOpenFile?: (path: string, line?: number, column?: number) => void;
8283
}
8384

8485
const ToolCallMessage: React.FC<{
@@ -470,14 +471,122 @@ const getToolCallView = (
470471
};
471472
};
472473

473-
const MarkdownLink: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = ({
474+
type ParsedFileLink = {
475+
path: string;
476+
line?: number;
477+
column?: number;
478+
};
479+
480+
const parseLineNumber = (value: string | null): number | undefined => {
481+
if (!value) return undefined;
482+
const parsed = Number.parseInt(value, 10);
483+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
484+
};
485+
486+
const toZeroBasedPosition = (value: number | undefined): number | undefined => {
487+
if (value === undefined) return undefined;
488+
return value > 0 ? value - 1 : 0;
489+
};
490+
491+
const parseMarkdownFileHref = (href: string): ParsedFileLink | null => {
492+
const trimmedHref = href.trim();
493+
if (!trimmedHref || trimmedHref.startsWith('#')) {
494+
return null;
495+
}
496+
497+
if (/^(https?:|mailto:|tel:)/i.test(trimmedHref)) {
498+
return null;
499+
}
500+
501+
let workingHref = trimmedHref;
502+
let line: number | undefined;
503+
let column: number | undefined;
504+
505+
const hashIndex = workingHref.indexOf('#');
506+
if (hashIndex >= 0) {
507+
const fragment = workingHref.slice(hashIndex + 1);
508+
workingHref = workingHref.slice(0, hashIndex);
509+
const lineMatch = fragment.match(/^L(\d+)(?:C(\d+))?$/i);
510+
if (lineMatch) {
511+
line = parseLineNumber(lineMatch[1]);
512+
column = parseLineNumber(lineMatch[2] ?? null);
513+
}
514+
}
515+
516+
const queryIndex = workingHref.indexOf('?');
517+
if (queryIndex >= 0) {
518+
const queryString = workingHref.slice(queryIndex + 1);
519+
workingHref = workingHref.slice(0, queryIndex);
520+
const params = new URLSearchParams(queryString);
521+
line ??= parseLineNumber(params.get('line'));
522+
column ??= parseLineNumber(params.get('column'));
523+
}
524+
525+
if (workingHref.startsWith('file://')) {
526+
workingHref = decodeURIComponent(workingHref.slice('file://'.length));
527+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(workingHref)) {
528+
return null;
529+
}
530+
531+
const suffixMatch = workingHref.match(/^(.*):(\d+)(?::(\d+))?$/);
532+
if (suffixMatch) {
533+
workingHref = suffixMatch[1];
534+
line ??= parseLineNumber(suffixMatch[2]);
535+
column ??= parseLineNumber(suffixMatch[3] ?? null);
536+
}
537+
538+
const path = decodeURIComponent(workingHref).trim();
539+
if (!path) {
540+
return null;
541+
}
542+
543+
return {
544+
path,
545+
line: toZeroBasedPosition(line),
546+
column: toZeroBasedPosition(column ?? 0),
547+
};
548+
};
549+
550+
const MarkdownLink: React.FC<React.ComponentProps<'a'> & {
551+
onOpenFile?: (path: string, line?: number, column?: number) => void;
552+
}> = ({
474553
children,
554+
href,
555+
onClick,
556+
onOpenFile,
475557
...props
476-
}) => (
477-
<a {...props} target="_blank" rel="noreferrer noopener">
478-
{children}
479-
</a>
480-
);
558+
}) => {
559+
const parsedFileLink = href ? parseMarkdownFileHref(href) : null;
560+
561+
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
562+
onClick?.(event);
563+
if (event.defaultPrevented) {
564+
return;
565+
}
566+
567+
if (parsedFileLink && onOpenFile) {
568+
event.preventDefault();
569+
onOpenFile(parsedFileLink.path, parsedFileLink.line, parsedFileLink.column);
570+
return;
571+
}
572+
573+
if (!href || /^javascript:/i.test(href.trim())) {
574+
event.preventDefault();
575+
}
576+
};
577+
578+
return (
579+
<a
580+
{...props}
581+
href={href}
582+
onClick={handleClick}
583+
target={parsedFileLink ? undefined : '_blank'}
584+
rel={parsedFileLink ? undefined : 'noreferrer noopener'}
585+
>
586+
{children}
587+
</a>
588+
);
589+
};
481590

482591
const MarkdownInlineCode: React.FC<{
483592
children?: React.ReactNode;
@@ -559,11 +668,12 @@ const parseMarkdownParts = (content: string): MarkdownPart[] => {
559668

560669
const MarkdownTextBlock: React.FC<{
561670
content: string;
562-
}> = ({ content }) => (
671+
onOpenFile?: (path: string, line?: number, column?: number) => void;
672+
}> = ({ content, onOpenFile }) => (
563673
<ReactMarkdown
564674
remarkPlugins={[remarkGfm, remarkBreaks]}
565675
components={{
566-
a: MarkdownLink,
676+
a: ({ node: _node, ...props }) => <MarkdownLink {...props} onOpenFile={onOpenFile} />,
567677
code: MarkdownInlineCode,
568678
}}
569679
>
@@ -747,7 +857,8 @@ const DiffCodeBlock: React.FC<{
747857

748858
const StreamingMarkdownContent: React.FC<{
749859
content: string;
750-
}> = ({ content }) => (
860+
onOpenFile?: (path: string, line?: number, column?: number) => void;
861+
}> = ({ content, onOpenFile }) => (
751862
<div className="acp-message-markdown">
752863
{parseMarkdownParts(content).map((part, index) => {
753864
if (part.kind === 'code') {
@@ -762,7 +873,7 @@ const StreamingMarkdownContent: React.FC<{
762873
}
763874

764875
return (
765-
<MarkdownTextBlock key={`text-${index}`} content={part.content} />
876+
<MarkdownTextBlock key={`text-${index}`} content={part.content} onOpenFile={onOpenFile} />
766877
);
767878
})}
768879
</div>
@@ -771,10 +882,11 @@ const StreamingMarkdownContent: React.FC<{
771882
const TextMessage: React.FC<{
772883
message: AcpUserMessage | AcpAssistantMessage;
773884
onUndo?: () => void;
774-
}> = ({ message, onUndo }) => (
885+
onOpenFile?: (path: string, line?: number, column?: number) => void;
886+
}> = ({ message, onUndo, onOpenFile }) => (
775887
<div className={`acp-message acp-message-${message.role}`}>
776888
<div className="acp-message-content acp-message-content-with-actions">
777-
<StreamingMarkdownContent content={message.content} />
889+
<StreamingMarkdownContent content={message.content} onOpenFile={onOpenFile} />
778890
{message.role === 'user' && onUndo && (
779891
<div className="acp-message-actions">
780892
<button className="acp-undo-button" onClick={onUndo} title="Undo">
@@ -905,6 +1017,7 @@ export const AcpMessage: React.FC<AcpMessageProps> = ({
9051017
onToggle,
9061018
onPermissionResponse,
9071019
onUndo,
1020+
onOpenFile,
9081021
}) => {
9091022
switch (message.role) {
9101023
case 'tool_call':
@@ -937,9 +1050,9 @@ export const AcpMessage: React.FC<AcpMessageProps> = ({
9371050
/>
9381051
);
9391052
case 'user':
940-
return <TextMessage message={message} onUndo={onUndo} />;
1053+
return <TextMessage message={message} onUndo={onUndo} onOpenFile={onOpenFile} />;
9411054
case 'assistant':
942-
return <TextMessage message={message} />;
1055+
return <TextMessage message={message} onOpenFile={onOpenFile} />;
9431056
case 'thought':
9441057
if (!onToggle) return null;
9451058
return (

anycode/components/agent/AcpMessages.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface AcpMessagesProps {
2222
onTogglePermission: (index: number) => void;
2323
onPermissionResponse: (permissionId: string, optionId: string) => void;
2424
onUndoMessage?: (message: AcpUserMessage) => void;
25+
onOpenFile?: (path: string, line?: number, column?: number) => void;
2526
}
2627

2728
const AcpMessagesComponent: React.FC<AcpMessagesProps> = ({
@@ -37,6 +38,7 @@ const AcpMessagesComponent: React.FC<AcpMessagesProps> = ({
3738
onTogglePermission,
3839
onPermissionResponse,
3940
onUndoMessage,
41+
onOpenFile,
4042
}) => {
4143
if (messages.length === 0) {
4244
return (
@@ -148,6 +150,7 @@ const AcpMessagesComponent: React.FC<AcpMessagesProps> = ({
148150
toolResult={toolResult}
149151
toolUpdates={toolUpdates}
150152
onPermissionResponse={onPermissionResponse}
153+
onOpenFile={onOpenFile}
151154
onUndo={
152155
message.role === 'user' && onUndoMessage
153156
? () => onUndoMessage(message)

0 commit comments

Comments
 (0)