@@ -635,15 +635,45 @@ function findMarkdownLinks(line: string): LinkHit[] {
635635 return spans ;
636636}
637637
638+ /**
639+ * Whole `` ranges: bare-URL scanning cannot tell image markup
640+ * from links, so the resolver discards bare matches touching these ranges and
641+ * the spans above already skip them. Images stay non-openable by policy.
642+ */
643+ function findImageRanges ( line : string ) : { start : number ; end : number } [ ] {
644+ const ranges : { start : number ; end : number } [ ] = [ ] ;
645+ for ( const match of line . matchAll ( / ! \[ [ ^ \] ] * \] \( ( [ ^ ) \s ] + ) \) / g) ) {
646+ const start = match . index ?? 0 ;
647+ ranges . push ( { start, end : start + match [ 0 ] . length } ) ;
648+ }
649+ return ranges ;
650+ }
651+
638652/**
639653 * The link target under one source offset: bare URLs first (fidelity for
640- * URL-shaped link labels), then inline `[label](target)` spans.
654+ * URL-shaped link labels), then inline `[label](target)` spans. A bare match
655+ * fused across a link span's boundary (`[a](u1)[b](u2)` scans as one run) or
656+ * inside image markup is the matcher's artifact, not a link the line holds,
657+ * so only a bare match one span fully contains — or no span touches — counts.
641658 */
642659function markdownUrlAt ( line : string , offset : number ) : string | null {
660+ const spans = findMarkdownLinks ( line ) ;
661+ const images = findImageRanges ( line ) ;
643662 for ( const hit of findLinks ( line ) ) {
644- if ( offset >= hit . start && offset < hit . end ) return hit . url ;
663+ if ( offset >= hit . start && offset < hit . end ) {
664+ const fused = spans . some (
665+ ( span ) =>
666+ hit . start < span . end &&
667+ hit . end > span . start &&
668+ ( hit . start < span . start || hit . end > span . end ) ,
669+ ) ;
670+ const imaged = images . some (
671+ ( image ) => hit . start < image . end && hit . end > image . start ,
672+ ) ;
673+ if ( ! fused && ! imaged ) return hit . url ;
674+ }
645675 }
646- for ( const span of findMarkdownLinks ( line ) ) {
676+ for ( const span of spans ) {
647677 if ( offset >= span . start && offset < span . end ) return span . url ;
648678 }
649679 return null ;
@@ -734,13 +764,15 @@ function codeBlockLinkAt(
734764
735765/**
736766 * The markdown click target: the raw link target under terminal-absolute
737- * (x, y), or null when the cell paints no link. Walks from the hit leaf up
738- * to the nearest painted code block (assistant markdown paints through
739- * library CodeRenderables, one per block); clicks landing between blocks
740- * still resolve through the parent markdown node, which pairs the same full
741- * source with its own line info. TextRenderable rows never resolve here —
742- * their own armed node handlers own those clicks. Never throws: anything
743- * unexpected resolves to null so a missed click stays a missed click.
767+ * (x, y), or null when the cell paints no link. Walks from the hit leaf up to
768+ * the nearest painted code block (assistant markdown paints through library
769+ * CodeRenderables, one per block), and that first block decides: its answer
770+ * stands, with no retry at an ancestor, so a miss inside one block never
771+ * falls through to a wider ancestor that pairs the same column with a link
772+ * the narrower block already rejected. Clicks landing outside any block miss.
773+ * TextRenderable rows never resolve here — their own armed node handlers own
774+ * those clicks. Never throws: anything unexpected resolves to null so a
775+ * missed click stays a missed click.
744776 */
745777export function markdownLinkAt (
746778 renderer : CliRenderer ,
@@ -756,8 +788,7 @@ export function markdownLinkAt(
756788 }
757789 while ( current ) {
758790 if ( current instanceof CodeRenderable ) {
759- const url = codeBlockLinkAt ( current , x , y ) ;
760- if ( url !== null ) return url ;
791+ return codeBlockLinkAt ( current , x , y ) ;
761792 }
762793 current = current . parent ;
763794 }
0 commit comments