@@ -12,6 +12,12 @@ import {
1212} from "@opentui/core" ;
1313import { stringWidth } from "../view/height.js" ;
1414import { viewToTableContent , type McpStructuredView } from "../mcp-view.js" ;
15+ import {
16+ buildLinkLine ,
17+ findLinks ,
18+ paintLinkLine ,
19+ splitLinkSpans ,
20+ } from "../url-links.js" ;
1521import {
1622 splitAtSettledHeading ,
1723 withholdIncompleteHeading ,
@@ -198,7 +204,7 @@ function retextStreamRowBody(
198204 return false ;
199205 if ( node instanceof TextRenderable ) {
200206 if ( isMarkdownRow ( row ) ) return false ;
201- node . content = paintStreamRow ( row , layout ) . content ;
207+ paintPlainRowNode ( node , paintStreamRow ( row , layout ) ) ;
202208 return true ;
203209 }
204210
@@ -347,11 +353,7 @@ export function buildRowNode(
347353 }
348354
349355 if ( ! isMarkdownRow ( row ) ) {
350- const painted = paintStreamRow ( row , layout ) ;
351- return new TextRenderable ( ctx , {
352- content : painted . content ,
353- fg : painted . fg ,
354- } ) ;
356+ return buildPlainRowNode ( ctx , paintStreamRow ( row , layout ) ) ;
355357 }
356358
357359 const gutter = streamRowGutter ( row , layout ) ;
@@ -375,6 +377,44 @@ function markdownBodyOptions(gutter: PaintedStreamLine, width: number) {
375377 } as const ;
376378}
377379
380+ /**
381+ * A literal-text row's paint node: always a single text node, as before. Rows
382+ * holding URLs paint styled text (URL spans carry OSC-8 metadata) and arm as
383+ * Ctrl+click targets; URL-free rows paint the plain string they always have.
384+ */
385+ function buildPlainRowNode (
386+ ctx : CliRenderer ,
387+ painted : PaintedStreamLine ,
388+ ) : TextRenderable {
389+ const node = new TextRenderable ( ctx , {
390+ content : painted . content ,
391+ fg : painted . fg ,
392+ } ) ;
393+ paintPlainRowNode ( node , painted ) ;
394+ return node ;
395+ }
396+
397+ /**
398+ * Rewrite a plain row's text on its existing node. The node never changes
399+ * shape, so a URL appearing or disappearing repaints in place instead of
400+ * forcing a rebuild.
401+ */
402+ function paintPlainRowNode (
403+ node : TextRenderable ,
404+ painted : PaintedStreamLine ,
405+ ) : void {
406+ const lines = painted . content . split ( "\n" ) ;
407+ if ( ! lines . some ( ( line ) => findLinks ( line ) . length > 0 ) ) {
408+ node . content = painted . content ;
409+ node . fg = painted . fg ;
410+ return ;
411+ }
412+ paintLinkLine (
413+ node ,
414+ lines . map ( ( line ) => splitLinkSpans ( [ { text : line , fg : painted . fg } ] ) ) ,
415+ ) ;
416+ }
417+
378418/**
379419 * A markdown row's body. Most rows have no settled heading yet (no heading at
380420 * all, or the only one is still the open tail), and paint through a single
@@ -396,10 +436,10 @@ function createMarkdownBody(
396436 const content = markdownContent ( row ) ;
397437 const split = splitAtSettledHeading ( content ) ;
398438 if ( split === null ) {
439+ // Native incremental block stability: only the trailing block is unstable.
399440 return new MarkdownRenderable ( ctx , {
400441 ...markdownBodyOptions ( gutter , width ) ,
401442 content,
402- // Native incremental block stability: only the trailing block is unstable.
403443 streaming : row . streaming === true ,
404444 } ) ;
405445 }
@@ -454,8 +494,9 @@ function createStyledLinesRowRenderable(
454494
455495/**
456496 * 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.
497+ * arrow is its own renderable and can answer a click; a line holding URLs
498+ * paints styled text and arms as a Ctrl+click target (see url-links.ts);
499+ * every other line is a single text node, as before.
459500 */
460501function bodyLineNode (
461502 ctx : CliRenderer ,
@@ -464,17 +505,12 @@ function bodyLineNode(
464505) : TextRenderable | BoxRenderable {
465506 const split = onToggle === undefined ? null : splitTrailingArrow ( line ) ;
466507 if ( split === null || onToggle === undefined ) {
467- return new TextRenderable ( ctx , {
468- content : new StyledText ( diffLineChunks ( line ) ) ,
469- } ) ;
508+ return buildLinkLine ( ctx , splitLinkSpans ( line ) ) ;
470509 }
471510 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- ) ;
511+ const body = buildLinkLine ( ctx , splitLinkSpans ( split . body ) ) ;
512+ body . flexShrink = 0 ;
513+ wrapper . add ( body ) ;
478514 wrapper . add (
479515 new TextRenderable ( ctx , {
480516 content : new StyledText ( diffLineChunks ( [ split . arrow ] ) ) ,
0 commit comments