@@ -58,13 +58,27 @@ public bool IsLegacyConsole
5858 // Last URL used for tracking hyperlink state
5959 private string ? _lastUrl = null ;
6060
61+ // Rows that contained URLs in the last rendered frame; used to emit OSC 8 close
62+ // before re-rendering a row that has since lost all URL cells, so terminals don't
63+ // keep stale hyperlink metadata.
64+ private readonly HashSet < int > _rowsWithUrls = [ ] ;
65+
66+ // Identifies the buffer state we last synced _rowsWithUrls against. When the buffer
67+ // is replaced, resized, or its URL maps are wiped, this stops matching and we drop
68+ // the stale tracking before reading it.
69+ private IOutputBuffer ? _lastTrackedBuffer ;
70+ private int _lastTrackedRows ;
71+ private int _lastTrackedCols ;
72+ private int _lastTrackedUrlVersion ;
73+
6174 private readonly StringBuilder _lastOutputStringBuilder = new ( ) ;
6275 private bool _clearLastOutputPending ;
6376
6477 /// <summary>
65- /// Writes dirty cells from the buffer to the console. Hides cursor, iterates rows/cols,
66- /// skips clean cells, batches dirty cells into ANSI sequences, wraps URLs with OSC 8,
67- /// then renders sixel images. Cursor visibility is managed by <c>ApplicationMainLoop.SetCursor()</c>.
78+ /// Writes dirty cells from the buffer to the console. Iterates rows/cols, skips clean cells,
79+ /// batches dirty cells into ANSI sequences, emits OSC 8 hyperlink start/close around URL cells,
80+ /// and finally renders queued sixel images. Cursor visibility is managed by
81+ /// <c>ApplicationMainLoop.SetCursor()</c>.
6882 /// </summary>
6983 public virtual void Write ( IOutputBuffer buffer )
7084 {
@@ -77,6 +91,8 @@ public virtual void Write (IOutputBuffer buffer)
7791 Attribute ? redrawAttr = null ;
7892 int lastCol = - 1 ;
7993
94+ InvalidateRowsWithUrlsIfStale ( buffer , rows , cols ) ;
95+
8096 // Process each row
8197 for ( int row = top ; row < rows ; row ++ )
8298 {
@@ -93,9 +109,22 @@ public virtual void Write (IOutputBuffer buffer)
93109 return ;
94110 }
95111
112+ if ( ! IsLegacyConsole && buffer is OutputBufferImpl outputBuffer )
113+ {
114+ outputBuffer . SyncAutoUrlsForRow ( row ) ;
115+ }
116+
117+ bool rowHadUrlsPreviously = _rowsWithUrls . Contains ( row ) ;
118+ bool rowHasUrlsNow = ! IsLegacyConsole && RowContainsUrls ( buffer , row , cols ) ;
119+
96120 outputStringBuilder . Clear ( ) ;
97121 _lastUrl = null ; // Reset URL state at the start of each row
98122
123+ if ( ! IsLegacyConsole && rowHadUrlsPreviously && ! rowHasUrlsNow )
124+ {
125+ outputStringBuilder . Append ( EscSeqUtils . OSC_EndHyperlink ( ) ) ;
126+ }
127+
99128 // Process columns in row
100129 for ( int col = left ; col < cols ; col ++ )
101130 {
@@ -170,6 +199,21 @@ public virtual void Write (IOutputBuffer buffer)
170199 }
171200 }
172201
202+ // Track row's URL status BEFORE the early-exit so _rowsWithUrls stays consistent
203+ // with the buffer state — even for rows whose cells were all flushed via WriteToConsole
204+ // during the inner loop (leaving outputStringBuilder empty at this point).
205+ if ( ! IsLegacyConsole )
206+ {
207+ if ( rowHasUrlsNow )
208+ {
209+ _rowsWithUrls . Add ( row ) ;
210+ }
211+ else
212+ {
213+ _rowsWithUrls . Remove ( row ) ;
214+ }
215+ }
216+
173217 // Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink
174218 // may still be open in the terminal because it was started in a prior batch flushed by
175219 // WriteToConsole and the row ended (or only clean cells followed) before any cell with
@@ -197,9 +241,7 @@ public virtual void Write (IOutputBuffer buffer)
197241
198242 SetCursorPositionImpl ( lastCol , row ) ;
199243
200- // Wrap URLs with OSC 8 hyperlink sequences
201- StringBuilder processed = Osc8UrlLinker . WrapOsc8 ( outputStringBuilder ) ;
202- Write ( processed ) ;
244+ Write ( outputStringBuilder ) ;
203245 }
204246
205247 if ( IsLegacyConsole )
@@ -442,6 +484,11 @@ public string ToAnsi (IOutputBuffer buffer)
442484 return output . ToString ( ) ;
443485 }
444486
487+ if ( buffer is OutputBufferImpl outputBuffer )
488+ {
489+ outputBuffer . SyncAutoUrlsForAllRows ( ) ;
490+ }
491+
445492 StringBuilder ansiOutput = new ( ) ;
446493 Attribute ? lastAttr = null ;
447494
@@ -451,24 +498,45 @@ public string ToAnsi (IOutputBuffer buffer)
451498 }
452499
453500 /// <summary>
454- /// Writes buffered output to console, wrapping URLs with OSC 8 hyperlinks (non-legacy only),
455- /// then clears the buffer and advances <paramref name="lastCol"/> by <paramref name="outputWidth"/>.
501+ /// Writes buffered output to console, then clears the buffer and advances
502+ /// <paramref name="lastCol"/> by <paramref name="outputWidth"/>.
456503 /// </summary>
457504 private void WriteToConsole ( StringBuilder output , ref int lastCol , ref int outputWidth )
458505 {
459- if ( IsLegacyConsole )
460- {
461- Write ( output ) ;
462- }
463- else
464- {
465- // Wrap URLs with OSC 8 hyperlink sequences
466- StringBuilder processed = Osc8UrlLinker . WrapOsc8 ( output ) ;
467- Write ( processed ) ;
468- }
506+ Write ( output ) ;
469507
470508 output . Clear ( ) ;
471509 lastCol += outputWidth ;
472510 outputWidth = 0 ;
473511 }
512+
513+ private static bool RowContainsUrls ( IOutputBuffer buffer , int row , int cols )
514+ {
515+ for ( int col = 0 ; col < cols ; col ++ )
516+ {
517+ if ( ! string . IsNullOrEmpty ( buffer . GetCellUrl ( col , row ) ) )
518+ {
519+ return true ;
520+ }
521+ }
522+
523+ return false ;
524+ }
525+
526+ private void InvalidateRowsWithUrlsIfStale ( IOutputBuffer buffer , int rows , int cols )
527+ {
528+ int urlVersion = buffer is OutputBufferImpl outputBuffer ? outputBuffer . UrlStateVersion : 0 ;
529+
530+ if ( ! ReferenceEquals ( _lastTrackedBuffer , buffer )
531+ || _lastTrackedRows != rows
532+ || _lastTrackedCols != cols
533+ || _lastTrackedUrlVersion != urlVersion )
534+ {
535+ _rowsWithUrls . Clear ( ) ;
536+ _lastTrackedBuffer = buffer ;
537+ _lastTrackedRows = rows ;
538+ _lastTrackedCols = cols ;
539+ _lastTrackedUrlVersion = urlVersion ;
540+ }
541+ }
474542}
0 commit comments