@@ -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
8485const 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 ( / ^ ( h t t p s ? : | m a i l t o : | t e l : ) / 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 - z 0 - 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 || / ^ j a v a s c r i p t : / 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
482591const MarkdownInlineCode : React . FC < {
483592 children ?: React . ReactNode ;
@@ -559,11 +668,12 @@ const parseMarkdownParts = (content: string): MarkdownPart[] => {
559668
560669const 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
748858const 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<{
771882const 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 (
0 commit comments