@@ -12,6 +12,14 @@ import {
1212} from "@opentui/core" ;
1313import { stringWidth } from "../view/height.js" ;
1414import { viewToTableContent , type McpStructuredView } from "../mcp-view.js" ;
15+ import {
16+ armLinkLine ,
17+ buildLinkLine ,
18+ findLinks ,
19+ paintLinkLine ,
20+ splitLinkSpans ,
21+ splitWrappedLinkSpans ,
22+ } from "../url-links.js" ;
1523import {
1624 splitAtSettledHeading ,
1725 withholdIncompleteHeading ,
@@ -26,6 +34,7 @@ import {
2634 isSentenceRow ,
2735 MAIN_AGENT ,
2836 paintStreamRow ,
37+ plainRowWrapWidth ,
2938 rowGroupGap ,
3039 streamRowGutter ,
3140 toolRowLines ,
@@ -198,7 +207,12 @@ function retextStreamRowBody(
198207 return false ;
199208 if ( node instanceof TextRenderable ) {
200209 if ( isMarkdownRow ( row ) ) return false ;
201- node . content = paintStreamRow ( row , layout ) . content ;
210+ paintPlainRowNode (
211+ node ,
212+ row ,
213+ paintStreamRow ( row , layout ) ,
214+ plainRowWrapWidth ( row , layout ) ,
215+ ) ;
202216 return true ;
203217 }
204218
@@ -347,11 +361,12 @@ export function buildRowNode(
347361 }
348362
349363 if ( ! isMarkdownRow ( row ) ) {
350- const painted = paintStreamRow ( row , layout ) ;
351- return new TextRenderable ( ctx , {
352- content : painted . content ,
353- fg : painted . fg ,
354- } ) ;
364+ return buildPlainRowNode (
365+ ctx ,
366+ row ,
367+ paintStreamRow ( row , layout ) ,
368+ plainRowWrapWidth ( row , layout ) ,
369+ ) ;
355370 }
356371
357372 const gutter = streamRowGutter ( row , layout ) ;
@@ -375,6 +390,64 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
375390 } as const ;
376391}
377392
393+ /**
394+ * A literal-text row's paint node: always a single text node, as before. Rows
395+ * holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as
396+ * Ctrl+click targets; URL-free rows paint the plain string they always have.
397+ */
398+ function buildPlainRowNode (
399+ ctx : CliRenderer ,
400+ row : StreamRow ,
401+ painted : PaintedStreamLine ,
402+ wrapWidth : number ,
403+ ) : TextRenderable {
404+ const node = new TextRenderable ( ctx , {
405+ content : painted . content ,
406+ fg : painted . fg ,
407+ } ) ;
408+ paintPlainRowNode ( node , row , painted , wrapWidth ) ;
409+ return node ;
410+ }
411+
412+ /**
413+ * Links a plain row's pre-wrap text holds: wrapped fragments reassemble to
414+ * one of these, which is what tells a real wrap across a short fragment line
415+ * apart from a natural line break after the fact.
416+ */
417+ function plainRowSourceUrls ( row : StreamRow ) : string [ ] {
418+ return findLinks ( `${ row . text } \n${ row . summary ?? "" } ` ) . map ( ( hit ) => hit . url ) ;
419+ }
420+
421+ /**
422+ * Rewrite a plain row's text on its existing node. The node never changes
423+ * shape, so a URL appearing or disappearing repaints in place instead of
424+ * forcing a rebuild.
425+ */
426+ function paintPlainRowNode (
427+ node : TextRenderable ,
428+ row : StreamRow ,
429+ painted : PaintedStreamLine ,
430+ wrapWidth : number ,
431+ ) : void {
432+ const lines = painted . content . split ( "\n" ) ;
433+ if ( ! lines . some ( ( line ) => findLinks ( line ) . length > 0 ) ) {
434+ node . content = painted . content ;
435+ node . fg = painted . fg ;
436+ // Route through the armer so a retext that drops the last URL disarms
437+ // the handlers a previous arming installed (armLinkLine clears them).
438+ armLinkLine ( node , [ ] ) ;
439+ return ;
440+ }
441+ paintLinkLine (
442+ node ,
443+ splitWrappedLinkSpans (
444+ lines . map ( ( text ) => ( { text : text . trimEnd ( ) , fg : painted . fg } ) ) ,
445+ wrapWidth ,
446+ plainRowSourceUrls ( row ) ,
447+ ) ,
448+ ) ;
449+ }
450+
378451/**
379452 * A markdown row's body. Most rows have no settled heading yet (no heading at
380453 * all, or the only one is still the open tail), and paint through a single
@@ -396,10 +469,10 @@ function createMarkdownBody(
396469 const content = markdownContent ( row ) ;
397470 const split = splitAtSettledHeading ( content ) ;
398471 if ( split === null ) {
472+ // Native incremental block stability: only the trailing block is unstable.
399473 return new MarkdownRenderable ( ctx , {
400474 ...markdownBodyOptions ( gutter , width ) ,
401475 content,
402- // Native incremental block stability: only the trailing block is unstable.
403476 streaming : row . streaming === true ,
404477 } ) ;
405478 }
@@ -454,8 +527,9 @@ function createStyledLinesRowRenderable(
454527
455528/**
456529 * One painted body line. A line ending in an expand arrow is split so the
457- * arrow is its own renderable and can answer a click; every other line is a
458- * single text node, as before.
530+ * arrow is its own renderable and can answer a click; a line holding URLs
531+ * paints styled text and arms as a Ctrl+click target (see url-links.ts);
532+ * every other line is a single text node, as before.
459533 */
460534function bodyLineNode (
461535 ctx : CliRenderer ,
@@ -464,17 +538,12 @@ function bodyLineNode(
464538) : TextRenderable | BoxRenderable {
465539 const split = onToggle === undefined ? null : splitTrailingArrow ( line ) ;
466540 if ( split === null || onToggle === undefined ) {
467- return new TextRenderable ( ctx , {
468- content : new StyledText ( diffLineChunks ( line ) ) ,
469- } ) ;
541+ return buildLinkLine ( ctx , splitLinkSpans ( line ) ) ;
470542 }
471543 const wrapper = new BoxRenderable ( ctx , { flexDirection : "row" , flexGrow : 1 } ) ;
472- wrapper . add (
473- new TextRenderable ( ctx , {
474- content : new StyledText ( diffLineChunks ( split . body ) ) ,
475- flexShrink : 0 ,
476- } ) ,
477- ) ;
544+ const body = buildLinkLine ( ctx , splitLinkSpans ( split . body ) ) ;
545+ body . flexShrink = 0 ;
546+ wrapper . add ( body ) ;
478547 wrapper . add (
479548 new TextRenderable ( ctx , {
480549 content : new StyledText ( diffLineChunks ( [ split . arrow ] ) ) ,
0 commit comments