@@ -17,7 +17,9 @@ import { withTestRenderer } from "./harness";
1717import { appendStreamRow , replaceStreamRowAt } from "./shell/chrome" ;
1818import { createAppShell } from "./shell/index" ;
1919import {
20+ isOpenableUrl ,
2021 isUnderlined ,
22+ markdownLinkAt ,
2123 paintLinkLine ,
2224 resetUrlOpener ,
2325 setUrlOpener ,
@@ -518,6 +520,254 @@ describe("Ctrl+clicking a transcript URL", () => {
518520 } ) ;
519521} ) ;
520522
523+ describe ( "transcript markdown resolver edges (CL-7955)" , ( ) => {
524+ test ( "adjacent-link boundary cells miss and never resolve garbage" , async ( ) => {
525+ await withTestRenderer (
526+ async ( h ) => {
527+ const shell = createAppShell ( h . renderer , {
528+ terminal : { columns : 80 , rows : 24 } ,
529+ wireKeys : false ,
530+ run : "idle" ,
531+ } ) ;
532+ const opened : string [ ] = [ ] ;
533+ setUrlOpener ( ( url ) => {
534+ opened . push ( url ) ;
535+ } ) ;
536+ try {
537+ appendStreamRow ( shell , {
538+ role : "assistant" ,
539+ text : "[a](https://a.com)[b](https://b.com)" ,
540+ } ) ;
541+ const painted = await waitForPaintedCell ( h , "a.com" ) ;
542+ const line = h . captureCharFrame ( ) . split ( "\n" ) [ painted . y ] ?? "" ;
543+
544+ // Every resolved cell is a real openable target: the junction
545+ // between two adjacent links must miss rather than fuse their
546+ // sources into a garbage URL.
547+ for ( let x = 0 ; x < line . length ; x += 1 ) {
548+ const hit = markdownLinkAt ( h . renderer , x , painted . y ) ;
549+ if ( hit === null ) continue ;
550+ expect ( isOpenableUrl ( hit ) ) . toBe ( true ) ;
551+ expect ( [ `https://a.com` , `https://b.com` ] ) . toContain ( hit ) ;
552+ }
553+
554+ // The junction cell itself (the ")" before "b (") misses, and
555+ // Ctrl+clicking it opens nothing.
556+ const junction = line . indexOf ( ")b (" ) ;
557+ expect ( junction ) . toBeGreaterThan ( - 1 ) ;
558+ expect ( markdownLinkAt ( h . renderer , junction , painted . y ) ) . toBeNull ( ) ;
559+ await h . mockMouse . click ( junction , painted . y , 0 , {
560+ modifiers : { ctrl : true } ,
561+ } ) ;
562+ await h . renderOnce ( ) ;
563+ expect ( opened ) . toEqual ( [ ] ) ;
564+
565+ // Either side still opens its own target: the labels are
566+ // unambiguous, so the miss stays pinned to the boundary.
567+ const labelA = findCell ( h . captureCharFrame ( ) , " a (" ) ;
568+ expect ( labelA ) . not . toBeNull ( ) ;
569+ await h . mockMouse . click ( defined ( labelA ) . x + 1 , defined ( labelA ) . y , 0 , {
570+ modifiers : { ctrl : true } ,
571+ } ) ;
572+ await h . renderOnce ( ) ;
573+ expect ( opened ) . toEqual ( [ "https://a.com" ] ) ;
574+
575+ opened . length = 0 ;
576+ const targetB = findCell ( h . captureCharFrame ( ) , "https://b.com" ) ;
577+ expect ( targetB ) . not . toBeNull ( ) ;
578+ await h . mockMouse . click ( defined ( targetB ) . x , defined ( targetB ) . y , 0 , {
579+ modifiers : { ctrl : true } ,
580+ } ) ;
581+ await h . renderOnce ( ) ;
582+ expect ( opened ) . toEqual ( [ "https://b.com" ] ) ;
583+ } finally {
584+ resetUrlOpener ( ) ;
585+ shell . dispose ( ) ;
586+ }
587+ } ,
588+ { width : 80 , height : 24 } ,
589+ ) ;
590+ } ) ;
591+
592+ test ( "non-link prose in a markdown row misses" , async ( ) => {
593+ await withTestRenderer (
594+ async ( h ) => {
595+ const shell = createAppShell ( h . renderer , {
596+ terminal : { columns : 80 , rows : 24 } ,
597+ wireKeys : false ,
598+ run : "idle" ,
599+ } ) ;
600+ const opened : string [ ] = [ ] ;
601+ setUrlOpener ( ( url ) => {
602+ opened . push ( url ) ;
603+ } ) ;
604+ try {
605+ appendStreamRow ( shell , {
606+ role : "assistant" ,
607+ text : "see https://example.com/docs ok" ,
608+ } ) ;
609+ const bare = await waitForPaintedCell ( h , "example.com/docs" ) ;
610+ const prose = findCell ( h . captureCharFrame ( ) , "see " ) ;
611+ expect ( prose ) . not . toBeNull ( ) ;
612+ const at = defined ( prose ) ;
613+ expect ( markdownLinkAt ( h . renderer , at . x , at . y ) ) . toBeNull ( ) ;
614+ await h . mockMouse . click ( at . x , at . y , 0 , {
615+ modifiers : { ctrl : true } ,
616+ } ) ;
617+ await h . renderOnce ( ) ;
618+ expect ( opened ) . toEqual ( [ ] ) ;
619+
620+ await h . mockMouse . click ( bare . x , bare . y , 0 , {
621+ modifiers : { ctrl : true } ,
622+ } ) ;
623+ await h . renderOnce ( ) ;
624+ expect ( opened ) . toEqual ( [ "https://example.com/docs" ] ) ;
625+ } finally {
626+ resetUrlOpener ( ) ;
627+ shell . dispose ( ) ;
628+ }
629+ } ,
630+ { width : 80 , height : 24 } ,
631+ ) ;
632+ } ) ;
633+
634+ test ( "image markup never opens, even with a URL-shaped label" , async ( ) => {
635+ await withTestRenderer (
636+ async ( h ) => {
637+ const shell = createAppShell ( h . renderer , {
638+ terminal : { columns : 80 , rows : 24 } ,
639+ wireKeys : false ,
640+ run : "idle" ,
641+ } ) ;
642+ const opened : string [ ] = [ ] ;
643+ setUrlOpener ( ( url ) => {
644+ opened . push ( url ) ;
645+ } ) ;
646+ try {
647+ appendStreamRow ( shell , {
648+ role : "assistant" ,
649+ text : "see  ok" ,
650+ } ) ;
651+ const logo = await waitForPaintedCell ( h , "logo" ) ;
652+ expect ( markdownLinkAt ( h . renderer , logo . x , logo . y ) ) . toBeNull ( ) ;
653+ await h . mockMouse . click ( logo . x , logo . y , 0 , {
654+ modifiers : { ctrl : true } ,
655+ } ) ;
656+ await h . renderOnce ( ) ;
657+ expect ( opened ) . toEqual ( [ ] ) ;
658+
659+ // A URL-shaped image label paints as URL text but stays an
660+ // image: Ctrl+clicking it must not open the label.
661+ appendStreamRow ( shell , {
662+ role : "assistant" ,
663+ text : "see  ok" ,
664+ } ) ;
665+ const evil = await waitForPaintedCell ( h , "evil.example" ) ;
666+ await h . mockMouse . click ( evil . x + 1 , evil . y , 0 , {
667+ modifiers : { ctrl : true } ,
668+ } ) ;
669+ await h . renderOnce ( ) ;
670+ expect ( opened ) . toEqual ( [ ] ) ;
671+ } finally {
672+ resetUrlOpener ( ) ;
673+ shell . dispose ( ) ;
674+ }
675+ } ,
676+ { width : 80 , height : 24 } ,
677+ ) ;
678+ } ) ;
679+
680+ test ( "a markdown bare URL wrapped across rows opens the full target" , async ( ) => {
681+ await withTestRenderer (
682+ async ( h ) => {
683+ const shell = createAppShell ( h . renderer , {
684+ terminal : { columns : 40 , rows : 24 } ,
685+ wireKeys : false ,
686+ run : "idle" ,
687+ } ) ;
688+ const opened : string [ ] = [ ] ;
689+ setUrlOpener ( ( url ) => {
690+ opened . push ( url ) ;
691+ } ) ;
692+ try {
693+ const full =
694+ "https://example.com/abcdefghijklmnopqrstuvwxyz0123456789" ;
695+ appendStreamRow ( shell , {
696+ role : "assistant" ,
697+ text : `checking ${ full } today` ,
698+ } ) ;
699+ const first = await waitForPaintedCell ( h , "example.com" ) ;
700+ await h . mockMouse . click ( first . x , first . y , 0 , {
701+ modifiers : { ctrl : true } ,
702+ } ) ;
703+ await h . renderOnce ( ) ;
704+ expect ( opened ) . toEqual ( [ full ] ) ;
705+ } finally {
706+ resetUrlOpener ( ) ;
707+ shell . dispose ( ) ;
708+ }
709+ } ,
710+ { width : 40 , height : 24 } ,
711+ ) ;
712+ } ) ;
713+
714+ test ( "a markdown link still opens at its post-scroll position" , async ( ) => {
715+ await withTestRenderer (
716+ async ( h ) => {
717+ const shell = createAppShell ( h . renderer , {
718+ terminal : { columns : 80 , rows : 24 } ,
719+ wireKeys : false ,
720+ run : "idle" ,
721+ } ) ;
722+ const opened : string [ ] = [ ] ;
723+ setUrlOpener ( ( url ) => {
724+ opened . push ( url ) ;
725+ } ) ;
726+ try {
727+ for ( let i = 0 ; i < 25 ; i += 1 ) {
728+ appendStreamRow ( shell , {
729+ role : "assistant" ,
730+ text : `filler line ${ i } ` ,
731+ } ) ;
732+ }
733+ appendStreamRow ( shell , {
734+ role : "assistant" ,
735+ text : "see https://example.com/docs ok" ,
736+ } ) ;
737+ for ( let i = 0 ; i < 3 ; i += 1 ) {
738+ appendStreamRow ( shell , {
739+ role : "assistant" ,
740+ text : `trailing filler ${ i } ` ,
741+ } ) ;
742+ }
743+ const before = await waitForPaintedCell ( h , "example.com/docs" ) ;
744+ for ( let i = 0 ; i < 2 ; i += 1 ) {
745+ await h . mockMouse . scroll ( before . x , before . y , "up" ) ;
746+ }
747+ // Let in-flight scroll work land before clicking: a Ctrl+click
748+ // whose down/up straddles a scroll re-render never arms, so the
749+ // keeper settles first and tests the post-scroll position itself.
750+ await new Promise ( ( r ) => setTimeout ( r , 100 ) ) ;
751+ await h . renderOnce ( ) ;
752+ await h . renderOnce ( ) ;
753+ const after = findCell ( h . captureCharFrame ( ) , "example.com/docs" ) ;
754+ expect ( after ) . not . toBeNull ( ) ;
755+ expect ( defined ( after ) . y ) . not . toBe ( before . y ) ;
756+ await h . mockMouse . click ( defined ( after ) . x , defined ( after ) . y , 0 , {
757+ modifiers : { ctrl : true } ,
758+ } ) ;
759+ await h . renderOnce ( ) ;
760+ expect ( opened ) . toEqual ( [ "https://example.com/docs" ] ) ;
761+ } finally {
762+ resetUrlOpener ( ) ;
763+ shell . dispose ( ) ;
764+ }
765+ } ,
766+ { width : 80 , height : 24 } ,
767+ ) ;
768+ } ) ;
769+ } ) ;
770+
521771/** Poll until `needle` paints, rendering between tries. */
522772async function waitForPaintedCell (
523773 h : {
0 commit comments