44 * holding Ctrl over a link highlights it, press-and-release on the same URL
55 * opens it. Assistant markdown paints through childless library renderers
66 * with no node to arm, so it is covered by a bubbling handler on the
7- * transcript root (armMarkdownLinks) that resolves clicks through the
8- * renderer's getLinkAt link map — click-to-open only, no hover highlight.
7+ * transcript root (armMarkdownLinks) that resolves clicks through
8+ * markdownLinkAt below — click-to-open only, no hover highlight.
99 *
1010 * The gesture is modifier-gated end to end. Without the modifier nothing here
1111 * runs: rows keep today's expand and selection behavior, and with mouse
1212 * capture off (Alt+M) the terminal owns every click because OpenTUI never
1313 * sees one. Only http(s) targets ever open; every other scheme is ignored.
1414 */
1515import {
16+ CodeRenderable ,
17+ Renderable ,
1618 StyledText ,
1719 TextAttributes ,
1820 TextRenderable ,
@@ -21,8 +23,8 @@ import {
2123 link as linkChunk ,
2224 underline as underlineChunk ,
2325 type CliRenderer ,
26+ type LineInfo ,
2427 type MouseEvent ,
25- type Renderable ,
2628 type TextChunk ,
2729} from "@opentui/core" ;
2830import { stringWidth } from "./view/height.js" ;
@@ -559,9 +561,8 @@ export function armLinkLine(
559561 press = null ;
560562 if ( start !== null && isUrlOpenClick ( event ) && at ( event ) === start ) {
561563 openUrl ( start ) ;
562- // Armed rows register in the same link map getLinkAt reads, so the
563- // leaf's open would otherwise be repeated by the transcript-root
564- // armMarkdownLinks handler this event bubbles to. This handler runs
564+ // The bubbled release would otherwise be resolved again by the
565+ // transcript-root armMarkdownLinks handler. This handler runs
565566 // first in the bubble; stopping propagation starves the root of the
566567 // release and keeps exactly one open per gesture. The press
567568 // deliberately keeps bubbling so drag-select still works.
@@ -596,31 +597,203 @@ export function isUnderlined(attributes: number): boolean {
596597 return ( attributes & TextAttributes . UNDERLINE ) !== 0 ;
597598}
598599
600+ /**
601+ * Link-markup characters whose painted width the resolver cannot know. The
602+ * renderer conceals markdown link markup — observed: `[guide](…)` paints as
603+ * `guide (…)` — and highlight state is not readable from here, so each of
604+ * these characters is modeled as taking painted width 0 or 1. Every other
605+ * character paints at its measured width.
606+ */
607+ const CONCEALABLE = new Set ( [ "[" , "]" , "(" , ")" ] ) ;
608+
609+ /**
610+ * Inline `[label](target)` links in one source line. The label and the
611+ * target both open the target. Images (``) are skipped:
612+ * nothing specifies their click behavior, and a missed click is safer
613+ * than a wrong open.
614+ */
615+ function findMarkdownLinks ( line : string ) : LinkHit [ ] {
616+ const spans : LinkHit [ ] = [ ] ;
617+ const pattern = / \[ ( [ ^ \] ] * ) \] \( ( [ ^ ) \s ] + ) \) / g;
618+ for ( const match of line . matchAll ( pattern ) ) {
619+ const index = match . index ?? 0 ;
620+ if ( index > 0 && line [ index - 1 ] === "!" ) continue ;
621+ const url = match [ 2 ] ?? "" ;
622+ const rawStart = index + match [ 0 ] . lastIndexOf ( url ) ;
623+ const end = trimUrlEnd ( line , rawStart , rawStart + url . length ) ;
624+ if ( end <= rawStart ) continue ;
625+ const target = line . slice ( rawStart , end ) ;
626+ const label = match [ 1 ] ?? "" ;
627+ if ( label . length > 0 )
628+ spans . push ( {
629+ url : target ,
630+ start : index + 1 ,
631+ end : index + 1 + label . length ,
632+ } ) ;
633+ spans . push ( { url : target , start : rawStart , end } ) ;
634+ }
635+ return spans ;
636+ }
637+
638+ /**
639+ * The link target under one source offset: bare URLs first (fidelity for
640+ * URL-shaped link labels), then inline `[label](target)` spans.
641+ */
642+ function markdownUrlAt ( line : string , offset : number ) : string | null {
643+ for ( const hit of findLinks ( line ) ) {
644+ if ( offset >= hit . start && offset < hit . end ) return hit . url ;
645+ }
646+ for ( const span of findMarkdownLinks ( line ) ) {
647+ if ( offset >= span . start && offset < span . end ) return span . url ;
648+ }
649+ return null ;
650+ }
651+
652+ /**
653+ * Source offsets a painted column can mean within one rendered row. Each
654+ * offset starts painting somewhere in [min, max] (the spread comes from
655+ * concealable markup before it) and paints up to wMax wide; the column hits
656+ * the offset when it falls in that range. Columns before any markup map
657+ * exactly; around markup the set holds neighbors too — the caller opens
658+ * only when every plausible offset agrees on one URL.
659+ */
660+ function paintedColumnToSource (
661+ line : string ,
662+ base : number ,
663+ length : number ,
664+ column : number ,
665+ ) : number [ ] {
666+ if ( column < 0 ) return [ ] ;
667+ const plausible : number [ ] = [ ] ;
668+ let min = 0 ;
669+ let max = 0 ;
670+ let offset = base ;
671+ const end = Math . min ( line . length , base + length ) ;
672+ while ( offset < end ) {
673+ const char = line [ offset ] ?? "" ;
674+ const codePoint = line . codePointAt ( offset ) ?? 0 ;
675+ const wMax = CONCEALABLE . has ( char )
676+ ? 1
677+ : stringWidth ( String . fromCodePoint ( codePoint ) ) ;
678+ if ( min <= column && column < max + wMax ) plausible . push ( offset ) ;
679+ min += CONCEALABLE . has ( char ) ? 0 : wMax ;
680+ max += wMax ;
681+ if ( min > column ) break ;
682+ offset += codePoint > 0xffff ? 2 : 1 ;
683+ }
684+ return plausible ;
685+ }
686+
687+ /**
688+ * The link target under terminal-absolute (x, y) inside one painted code
689+ * block: the row maps through the block's own line info to a source line,
690+ * the column maps to plausible source offsets, and the click opens only
691+ * when every plausible offset agrees on one URL — concealment ambiguity
692+ * misses rather than opening wrong. Stale layout or an unexpected library
693+ * shape resolves to null, never throws.
694+ */
695+ function codeBlockLinkAt (
696+ block : CodeRenderable ,
697+ x : number ,
698+ y : number ,
699+ ) : string | null {
700+ let content : string ;
701+ let info : LineInfo ;
702+ try {
703+ content = block . content ;
704+ info = block . lineInfo ;
705+ } catch {
706+ return null ;
707+ }
708+ const row = y - block . screenY ;
709+ const column = x - block . screenX ;
710+ if ( row < 0 || column < 0 ) return null ;
711+ const source = info . lineSources [ row ] ;
712+ const base = info . lineStartCols [ row ] ;
713+ const length = info . lineWidthCols [ row ] ;
714+ if (
715+ source === undefined ||
716+ base === undefined ||
717+ length === undefined ||
718+ ! Number . isInteger ( source ) ||
719+ ! Number . isInteger ( base ) ||
720+ ! Number . isInteger ( length )
721+ )
722+ return null ;
723+ const line = content . split ( "\n" ) [ source ] ;
724+ if ( typeof line !== "string" ) return null ;
725+ let found : string | null = null ;
726+ for ( const offset of paintedColumnToSource ( line , base , length , column ) ) {
727+ const url = markdownUrlAt ( line , offset ) ;
728+ if ( url === null ) continue ;
729+ if ( found === null ) found = url ;
730+ else if ( found !== url ) return null ;
731+ }
732+ return found ;
733+ }
734+
735+ /**
736+ * 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.
744+ */
745+ export function markdownLinkAt (
746+ renderer : CliRenderer ,
747+ x : number ,
748+ y : number ,
749+ ) : string | null {
750+ try {
751+ let current : Renderable | null | undefined ;
752+ try {
753+ current = Renderable . renderablesByNumber . get ( renderer . hitTest ( x , y ) ) ;
754+ } catch {
755+ return null ;
756+ }
757+ while ( current ) {
758+ if ( current instanceof CodeRenderable ) {
759+ const url = codeBlockLinkAt ( current , x , y ) ;
760+ if ( url !== null ) return url ;
761+ }
762+ current = current . parent ;
763+ }
764+ return null ;
765+ } catch {
766+ return null ;
767+ }
768+ }
769+
599770/**
600771 * Arm a transcript ancestor as the markdown click target: mouse events bubble
601772 * up from the hit leaf, and markdown blocks paint through childless library
602773 * renderers with no node of ours to arm, so this ancestor handler is the only
603774 * hook that sees their clicks. Ctrl+press stores the link under the pointer
604- * (getLinkAt reads the same terminal-absolute coordinates events carry); the
605- * open fires on release only over the same URL, so a press on a link that
775+ * (markdownLinkAt reads the same terminal-absolute coordinates events carry);
776+ * the open fires on release only over the same URL, so a press on a link that
606777 * drags away never opens. Armed rows stop propagation after opening
607778 * themselves, so a click there still opens exactly once; everything goes
608779 * through openUrl, which gates to http(s) — markdown links can carry any
609- * scheme and getLinkAt hands the raw target back.
780+ * scheme and markdownLinkAt hands the raw target back.
610781 */
611782export function armMarkdownLinks (
612783 target : Renderable ,
613784 renderer : CliRenderer ,
614785) : void {
615786 let press : string | null = null ;
616787 target . onMouseDown = ( event ) => {
617- press = isUrlOpenClick ( event ) ? renderer . getLinkAt ( event . x , event . y ) : null ;
788+ press = isUrlOpenClick ( event )
789+ ? markdownLinkAt ( renderer , event . x , event . y )
790+ : null ;
618791 } ;
619792 target . onMouseUp = ( event ) => {
620793 const start = press ;
621794 press = null ;
622795 if ( start === null || ! isUrlOpenClick ( event ) ) return ;
623- if ( renderer . getLinkAt ( event . x , event . y ) === start ) openUrl ( start ) ;
796+ if ( markdownLinkAt ( renderer , event . x , event . y ) === start ) openUrl ( start ) ;
624797 } ;
625798 target . onMouseOut = ( ) => {
626799 press = null ;
0 commit comments