11/**
22 * URL click-through (CL-7346): Ctrl+click opens an http(s) URL in the
3- * default browser; a plain click keeps today's row behavior.
3+ * default browser; a plain click keeps today's row behavior. Armed
4+ * plain/structured rows open through their own node handlers (with hover
5+ * highlight); assistant markdown opens through the bubbling transcript
6+ * handler — click only, no hover highlight.
47 *
58 * The opener is mocked (setUrlOpener) — no test spawns a real browser.
69 * Whether a real terminal reports the Ctrl modifier is a harness blind
@@ -339,7 +342,7 @@ describe("Ctrl+clicking a transcript URL", () => {
339342 ) ;
340343 } ) ;
341344
342- test ( "assistant markdown links stay terminal business (no opener call) " , async ( ) => {
345+ test ( "assistant markdown bare URL and link label open on Ctrl+click " , async ( ) => {
343346 await withTestRenderer (
344347 async ( h ) => {
345348 const shell = createAppShell ( h . renderer , {
@@ -352,31 +355,120 @@ describe("Ctrl+clicking a transcript URL", () => {
352355 opened . push ( url ) ;
353356 } ) ;
354357 try {
355- // Markdown prose paints through childless library renderers with
356- // no text-leaf API to arm or hit-test (docs/TUI.md), so neither
357- // the bare URL nor the explicit link label opens through us.
358- // The real terminal owns those cells; this pins that remainder.
358+ // Markdown blocks paint through childless library renderers, so
359+ // their clicks are only visible through the bubbling transcript
360+ // handler armed by createAppShell.
359361 appendStreamRow ( shell , {
360362 role : "assistant" ,
361363 text : "see https://example.com/docs and [guide](https://example.com/guide) ok" ,
362364 } ) ;
363365 // Assistant rows are markdown; their blocks highlight
364- // asynchronously (see shell.test.ts), so the frame only carries
365- // the prose after a settle.
366- await new Promise ( ( resolve ) => setTimeout ( resolve , 250 ) ) ;
366+ // asynchronously (see shell.test.ts), so wait for the paint
367+ // instead of sleeping a fixed settle.
368+ const bare = await waitForPaintedCell ( h , "example.com/docs" ) ;
369+ await h . mockMouse . click ( bare . x , bare . y , 0 , {
370+ modifiers : { ctrl : true } ,
371+ } ) ;
372+ await h . renderOnce ( ) ;
373+ expect ( opened ) . toEqual ( [ "https://example.com/docs" ] ) ;
374+
375+ opened . length = 0 ;
376+ const label = await waitForPaintedCell ( h , "guide" ) ;
377+ await h . mockMouse . click ( label . x , label . y , 0 , {
378+ modifiers : { ctrl : true } ,
379+ } ) ;
380+ await h . renderOnce ( ) ;
381+ expect ( opened ) . toEqual ( [ "https://example.com/guide" ] ) ;
382+ } finally {
383+ resetUrlOpener ( ) ;
384+ shell . dispose ( ) ;
385+ }
386+ } ,
387+ { width : 80 , height : 24 } ,
388+ ) ;
389+ } ) ;
390+
391+ test ( "plain click on a markdown link does not open" , async ( ) => {
392+ await withTestRenderer (
393+ async ( h ) => {
394+ const shell = createAppShell ( h . renderer , {
395+ terminal : { columns : 80 , rows : 24 } ,
396+ wireKeys : false ,
397+ run : "idle" ,
398+ } ) ;
399+ const opened : string [ ] = [ ] ;
400+ setUrlOpener ( ( url ) => {
401+ opened . push ( url ) ;
402+ } ) ;
403+ try {
404+ appendStreamRow ( shell , {
405+ role : "assistant" ,
406+ text : "see https://example.com/docs ok" ,
407+ } ) ;
408+ const bare = await waitForPaintedCell ( h , "example.com/docs" ) ;
409+ await h . mockMouse . click ( bare . x , bare . y ) ;
367410 await h . renderOnce ( ) ;
411+ expect ( opened ) . toEqual ( [ ] ) ;
412+ } finally {
413+ resetUrlOpener ( ) ;
414+ shell . dispose ( ) ;
415+ }
416+ } ,
417+ { width : 80 , height : 24 } ,
418+ ) ;
419+ } ) ;
368420
369- const bare = findCell ( h . captureCharFrame ( ) , "example.com/docs" ) ;
370- expect ( bare ) . not . toBeNull ( ) ;
371- await h . mockMouse . click ( defined ( bare ) . x , defined ( bare ) . y , 0 , {
421+ test ( "a markdown link to a non-http(s) target never opens" , async ( ) => {
422+ await withTestRenderer (
423+ async ( h ) => {
424+ const shell = createAppShell ( h . renderer , {
425+ terminal : { columns : 80 , rows : 24 } ,
426+ wireKeys : false ,
427+ run : "idle" ,
428+ } ) ;
429+ const opened : string [ ] = [ ] ;
430+ setUrlOpener ( ( url ) => {
431+ opened . push ( url ) ;
432+ } ) ;
433+ try {
434+ appendStreamRow ( shell , {
435+ role : "assistant" ,
436+ text : "see [target](custom://thing/pull/1) ok" ,
437+ } ) ;
438+ const label = await waitForPaintedCell ( h , "target" ) ;
439+ await h . mockMouse . click ( label . x , label . y , 0 , {
372440 modifiers : { ctrl : true } ,
373441 } ) ;
374442 await h . renderOnce ( ) ;
375443 expect ( opened ) . toEqual ( [ ] ) ;
444+ } finally {
445+ resetUrlOpener ( ) ;
446+ shell . dispose ( ) ;
447+ }
448+ } ,
449+ { width : 80 , height : 24 } ,
450+ ) ;
451+ } ) ;
376452
377- const label = findCell ( h . captureCharFrame ( ) , "guide" ) ;
378- expect ( label ) . not . toBeNull ( ) ;
379- await h . mockMouse . click ( defined ( label ) . x , defined ( label ) . y , 0 , {
453+ test ( "Ctrl+press on a markdown link, release off it, does not open" , async ( ) => {
454+ await withTestRenderer (
455+ async ( h ) => {
456+ const shell = createAppShell ( h . renderer , {
457+ terminal : { columns : 80 , rows : 24 } ,
458+ wireKeys : false ,
459+ run : "idle" ,
460+ } ) ;
461+ const opened : string [ ] = [ ] ;
462+ setUrlOpener ( ( url ) => {
463+ opened . push ( url ) ;
464+ } ) ;
465+ try {
466+ appendStreamRow ( shell , {
467+ role : "assistant" ,
468+ text : "see https://example.com/docs and more prose here ok" ,
469+ } ) ;
470+ const bare = await waitForPaintedCell ( h , "example.com/docs" ) ;
471+ await h . mockMouse . drag ( bare . x , bare . y , bare . x + 30 , bare . y , 0 , {
380472 modifiers : { ctrl : true } ,
381473 } ) ;
382474 await h . renderOnce ( ) ;
@@ -389,4 +481,58 @@ describe("Ctrl+clicking a transcript URL", () => {
389481 { width : 80 , height : 24 } ,
390482 ) ;
391483 } ) ;
484+
485+ test ( "Ctrl+click on an armed plain-row link opens exactly once" , async ( ) => {
486+ await withTestRenderer (
487+ async ( h ) => {
488+ const shell = createAppShell ( h . renderer , {
489+ terminal : { columns : 80 , rows : 24 } ,
490+ wireKeys : false ,
491+ run : "idle" ,
492+ } ) ;
493+ const opened : string [ ] = [ ] ;
494+ setUrlOpener ( ( url ) => {
495+ opened . push ( url ) ;
496+ } ) ;
497+ try {
498+ // The armed row's own release handler opens and stops propagation;
499+ // the transcript-root markdown handler must not see the same
500+ // gesture and open the (getLinkAt-resolved) target a second time.
501+ appendStreamRow ( shell , {
502+ role : "user" ,
503+ text : "see https://example.com/x ok" ,
504+ } ) ;
505+ const link = await waitForPaintedCell ( h , "example.com" ) ;
506+ await h . mockMouse . click ( link . x , link . y , 0 , {
507+ modifiers : { ctrl : true } ,
508+ } ) ;
509+ await h . renderOnce ( ) ;
510+ expect ( opened ) . toEqual ( [ "https://example.com/x" ] ) ;
511+ } finally {
512+ resetUrlOpener ( ) ;
513+ shell . dispose ( ) ;
514+ }
515+ } ,
516+ { width : 80 , height : 24 } ,
517+ ) ;
518+ } ) ;
392519} ) ;
520+
521+ /** Poll until `needle` paints, rendering between tries. */
522+ async function waitForPaintedCell (
523+ h : {
524+ renderOnce : ( ) => Promise < void > ;
525+ captureCharFrame : ( ) => string ;
526+ } ,
527+ needle : string ,
528+ timeoutMs = 2000 ,
529+ ) : Promise < { readonly x : number ; readonly y : number } > {
530+ const deadline = Date . now ( ) + timeoutMs ;
531+ for ( ; ; ) {
532+ await h . renderOnce ( ) ;
533+ const cell = findCell ( h . captureCharFrame ( ) , needle ) ;
534+ if ( cell !== null ) return cell ;
535+ if ( Date . now ( ) > deadline ) throw new Error ( `never painted: ${ needle } ` ) ;
536+ await new Promise ( ( resolve ) => setTimeout ( resolve , 25 ) ) ;
537+ }
538+ }
0 commit comments