Skip to content

Commit 4a06f70

Browse files
pditommasoclaude
andauthored
Fix AnsiLogObserver sticky messages duplicated when lines wrap [ci fast] (#6852)
Account for terminal line wrapping when counting visual lines in the ANSI progress renderer. Previously only explicit newlines were counted, causing cursorUp to undershoot when messages exceeded terminal width. Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b621fc7 commit 4a06f70

File tree

2 files changed

+56
-1
lines changed

2 files changed

+56
-1
lines changed

modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,12 +345,36 @@ class AnsiLogObserver implements TraceObserverV2 {
345345
protected int printAndCountLines(String str) {
346346
if( str ) {
347347
printAnsiLines(str)
348-
return str.count(NEWLINE)
348+
return countVisualLines(str)
349349
}
350350
else
351351
return 0
352352
}
353353

354+
/**
355+
* Count the number of visual lines the string occupies on the terminal,
356+
* accounting for lines that wrap when they exceed the terminal width.
357+
*/
358+
protected int countVisualLines(String str) {
359+
final lines = str.split(NEWLINE, -1)
360+
int count = 0
361+
// the last element after split is always empty (trailing newline), skip it
362+
for( int i=0; i<lines.length-1; i++ ) {
363+
final visualLen = stripAnsi(lines[i]).length()
364+
// each line takes at least 1 visual line, plus extra lines for wrapping
365+
count += visualLen > 0 && cols > 0 ? Math.ceil((double)visualLen / cols).intValue() : 1
366+
}
367+
return count
368+
}
369+
370+
/**
371+
* Strip ANSI escape codes and OSC hyperlinks from a string
372+
* to determine its visual display width.
373+
*/
374+
protected static String stripAnsi(String str) {
375+
return ANSI_ESCAPE.matcher(str).replaceAll('')
376+
}
377+
354378
protected void renderSummary(WorkflowStats stats) {
355379
final delta = endTimestamp-startTimestamp
356380
if( enableSummary == false )
@@ -415,6 +439,7 @@ class AnsiLogObserver implements TraceObserverV2 {
415439

416440
private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/
417441
private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/
442+
private final static Pattern ANSI_ESCAPE = ~/\033\[[0-9;]*[a-zA-Z]|\033][^\007]*\007/
418443

419444
// OSC 8 hyperlink escape sequences (using BEL as String Terminator)
420445
private final static String HYPERLINK_START = '\033]8;;'

modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ class AnsiLogObserverTest extends Specification {
147147
's3://bucket/work/4e/486876abc' | 's3://bucket/work/4e/486876abc'
148148
}
149149

150+
def 'should strip ansi escape codes' () {
151+
expect:
152+
AnsiLogObserver.stripAnsi('hello') == 'hello'
153+
AnsiLogObserver.stripAnsi('\033[32mgreen\033[0m') == 'green'
154+
AnsiLogObserver.stripAnsi('\033[1;31mbold red\033[0m') == 'bold red'
155+
AnsiLogObserver.stripAnsi('\033]8;;http://example.com\007link\033]8;;\007') == 'link'
156+
AnsiLogObserver.stripAnsi('\033[2m[\033[0m\033[34mab/123456\033[0m\033[2m] \033[0mfoo') == '[ab/123456] foo'
157+
}
158+
159+
@Unroll
160+
def 'should count visual lines with wrapping' () {
161+
given:
162+
def observer = new AnsiLogObserver()
163+
observer.@cols = COLS
164+
165+
expect:
166+
observer.countVisualLines(INPUT) == EXPECTED
167+
168+
where:
169+
COLS | INPUT | EXPECTED
170+
80 | 'short line\n' | 1
171+
80 | 'line1\nline2\n' | 2
172+
80 | 'a' * 80 + '\n' | 1 // exactly fits, no wrap
173+
80 | 'a' * 81 + '\n' | 2 // wraps to 2 lines
174+
80 | 'a' * 160 + '\n' | 2 // exactly 2 lines
175+
80 | 'a' * 161 + '\n' | 3 // wraps to 3 lines
176+
40 | 'a' * 100 + '\n' | 3 // 100 chars in 40-col terminal
177+
80 | 'short\n' + 'a' * 200 + '\n'| 4 // 1 + 3 lines
178+
}
179+
150180
def 'should not render hyperlink when cleanup is enabled' () {
151181
given:
152182
def session = Mock(Session) { getConfig() >> [cleanup: true] }

0 commit comments

Comments
 (0)