From 1522383573d17aacfb3a13af44146cb7b67d5d84 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 6 Feb 2026 17:04:51 +0100 Subject: [PATCH 1/3] Add clickable hyperlinks to task hash in ANSI progress display Make the task hash (e.g. [4e/486876]) a clickable hyperlink that opens the task work directory. Uses OSC 8 terminal escape sequences which are supported by modern terminals like iTerm2, Windows Terminal, and others. The hyperlink is only shown when cleanup is not enabled, since the work directory will be deleted after the run completes. Signed-off-by: Phil Ewels --- .../nextflow/trace/AnsiLogObserver.groovy | 17 +++++- .../nextflow/trace/ProgressRecord.groovy | 1 + .../nextflow/trace/WorkflowStats.groovy | 4 ++ .../nextflow/trace/AnsiLogObserverTest.groovy | 60 +++++++++++++++++++ .../nextflow/trace/ProgressRecordTest.groovy | 2 + .../nextflow/trace/WorkflowStatsTest.groovy | 8 +++ 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy index 58965a910e..00f3f41893 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy @@ -416,6 +416,18 @@ class AnsiLogObserver implements TraceObserverV2 { private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/ private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/ + // OSC 8 hyperlink escape sequences (using BEL as String Terminator) + private final static String HYPERLINK_START = '\033]8;;' + private final static String HYPERLINK_SEP = '\007' + private final static String HYPERLINK_END = '\033]8;;\007' + + protected static String hyperlink(String text, String url) { + if( !url ) + return text + final href = url.startsWith('/') ? 'file://' + url : url + return HYPERLINK_START + href + HYPERLINK_SEP + text + HYPERLINK_END + } + protected Ansi line(ProgressRecord stats) { final term = ansi() final float tot = stats.getTotalCount() @@ -442,9 +454,10 @@ class AnsiLogObserver implements TraceObserverV2 { // eg. 1 of 1 final numbs = " ${(int)com} of ${(int)tot}".toString() - // Task hash, eg: [fa/71091a] + // Task hash - make clickable hyperlink to work dir when available (and cleanup not enabled) + final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh term.a(Attribute.INTENSITY_FAINT).a('[').reset() - term.fg(Color.BLUE).a(hh).reset() + term.fg(Color.BLUE).a(hashDisplay).reset() term.a(Attribute.INTENSITY_FAINT).a('] ').reset() // Only show 'process > ' if the terminal has lots of width diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy index a0f1e71caa..4f115c0457 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/ProgressRecord.groovy @@ -32,6 +32,7 @@ class ProgressRecord implements Cloneable { final int index final String name // process name String hash // current task hash + String workDir // current task work directory URI String taskName // current task name int pending // number of new tasks ready to be submitted int submitted // number of tasks submitted for execution not yet started diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy index 1ebffe4659..900e5ce234 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/WorkflowStats.groovy @@ -301,6 +301,7 @@ class WorkflowStats implements Cloneable { void markSubmitted(TaskRun task) { final state = getOrCreateRecord(task.processor) state.hash = task.hashLog + state.workDir = task.workDirStr state.taskName = task.name state.pending -- state.submitted ++ @@ -350,6 +351,7 @@ class WorkflowStats implements Cloneable { ProgressRecord state = getOrCreateRecord(task.processor) state.taskName = task.name state.hash = task.hashLog + state.workDir = task.workDirStr if( status == TaskStatus.SUBMITTED ) { state.submitted -- @@ -401,6 +403,7 @@ class WorkflowStats implements Cloneable { if( trace ) { state.cached++ state.hash = task.hashLog + state.workDir = task.workDirStr state.taskName = task.name // global counters this.cachedMillis += getCpuTime(trace) @@ -409,6 +412,7 @@ class WorkflowStats implements Cloneable { else { state.stored++ state.hash = 'skipped' + state.workDir = null state.taskName = task.name } changeTimestamp = System.currentTimeMillis() diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy index 46c470a234..53b8c5ad6f 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/AnsiLogObserverTest.groovy @@ -16,6 +16,7 @@ package nextflow.trace +import nextflow.Session import spock.lang.Specification import spock.lang.Unroll @@ -108,4 +109,63 @@ class AnsiLogObserverTest extends Specification { } + def 'should create hyperlink' () { + expect: + // Local paths (starting with /) get file:// prefix added automatically + AnsiLogObserver.hyperlink('hash', '/path/to/work') == '\033]8;;file:///path/to/work\007hash\033]8;;\007' + // URLs with schemes are used as-is + AnsiLogObserver.hyperlink('hash', 's3://bucket/path') == '\033]8;;s3://bucket/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('hash', 'gs://bucket/path') == '\033]8;;gs://bucket/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('hash', 'az://container/path') == '\033]8;;az://container/path\007hash\033]8;;\007' + AnsiLogObserver.hyperlink('text', null) == 'text' + AnsiLogObserver.hyperlink('text', '') == 'text' + } + + def 'should render hash as hyperlink when workDir is set' () { + given: + def session = Mock(Session) { getConfig() >> [cleanup: false] } + def observer = new AnsiLogObserver() + observer.@session = session + observer.@labelWidth = 3 + observer.@cols = 190 + and: + def stats = new ProgressRecord(1, 'foo') + stats.submitted = 1 + stats.hash = '4e/486876' + stats.workDir = WORKDIR + + when: + def result = observer.line(stats).toString() + + then: + result.contains('\033]8;;' + EXPECTED_HREF + '\007') + result.contains('\033]8;;\007') + + where: + WORKDIR | EXPECTED_HREF + '/work/4e/486876abc' | 'file:///work/4e/486876abc' + 's3://bucket/work/4e/486876abc' | 's3://bucket/work/4e/486876abc' + } + + def 'should not render hyperlink when cleanup is enabled' () { + given: + def session = Mock(Session) { getConfig() >> [cleanup: true] } + def observer = new AnsiLogObserver() + observer.@session = session + observer.@labelWidth = 3 + observer.@cols = 190 + and: + def stats = new ProgressRecord(1, 'foo') + stats.submitted = 1 + stats.hash = '4e/486876' + stats.workDir = '/work/4e/486876abc' + + when: + def result = observer.line(stats).toString() + + then: + // Should NOT contain hyperlink start sequence + !result.contains('\033]8;;') + } + } diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy index c3aecb26dc..9b8e641dc7 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/ProgressRecordTest.groovy @@ -31,6 +31,8 @@ class ProgressRecordTest extends Specification { with(rec) { index == 10 name == 'foo' + hash == null + workDir == null pending == 0 submitted == 0 running == 0 diff --git a/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy b/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy index a1bc2e4b9d..9b7e5a147e 100644 --- a/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/trace/WorkflowStatsTest.groovy @@ -222,9 +222,11 @@ class WorkflowStatsTest extends Specification { def PENDING = 10 def SUBMITTED = 20 def HASH = 'xyz' + def WORKDIR = 'file:///work/xy/z123456' and: def task = Mock(TaskRun) { getHashLog() >> HASH + getWorkDirStr() >> WORKDIR getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -247,6 +249,7 @@ class WorkflowStatsTest extends Specification { rec.submitted == SUBMITTED +1 and: rec.hash == HASH + rec.workDir == WORKDIR } def 'should mark running' () { @@ -724,9 +727,11 @@ class WorkflowStatsTest extends Specification { given: def CACHED = 10 def STORED = 20 + def WORKDIR = 'file:///work/xy/z123456' and: def task = Mock(TaskRun) { getHashLog() >> 'XYZ' + getWorkDirStr() >> WORKDIR getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -749,6 +754,7 @@ class WorkflowStatsTest extends Specification { stats.cachedDuration == 5.sec and: rec.hash == 'XYZ' + rec.workDir == WORKDIR rec.cached == CACHED +1 rec.stored == STORED } @@ -760,6 +766,7 @@ class WorkflowStatsTest extends Specification { and: def task = Mock(TaskRun) { getHashLog() >> 'XYZ' + getWorkDirStr() >> 'file:///work/xy/z123456' getProcessor() >> Mock(TaskProcessor) { getId() >> 0 } } and: @@ -781,6 +788,7 @@ class WorkflowStatsTest extends Specification { rec.stored == STORED +1 and: rec.hash == 'skipped' + rec.workDir == null } From 123f3207e3d6b4e8276e7246e04576c88c0f3c6b Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Fri, 6 Feb 2026 17:27:03 +0100 Subject: [PATCH 2/3] Fix TowerJsonGeneratorTest for new workDir field in ProgressRecord Signed-off-by: Phil Ewels --- .../test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy index 1dd6547f1e..510cacb36a 100644 --- a/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy +++ b/plugins/nf-tower/src/test/io/seqera/tower/plugin/TowerJsonGeneratorTest.groovy @@ -113,6 +113,7 @@ class TowerJsonGeneratorTest extends Specification { progress.get(0) == [ index:1, name: 'foo', + workDir: null, pending: 0, submitted: 0, running: 0, @@ -133,6 +134,7 @@ class TowerJsonGeneratorTest extends Specification { progress[1] == [ index:2, name: 'bar', + workDir: null, pending: 1, submitted: 2, running: 3, From 73eba1b1a1503bf2a341045b8a08dbd24a3e84ed Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Wed, 11 Feb 2026 12:05:18 +0100 Subject: [PATCH 3/3] Minor comment [ci skip] Signed-off-by: Paolo Di Tommaso --- .../src/main/groovy/nextflow/trace/AnsiLogObserver.groovy | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy index 00f3f41893..44f61b8b7d 100644 --- a/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy @@ -454,7 +454,8 @@ class AnsiLogObserver implements TraceObserverV2 { // eg. 1 of 1 final numbs = " ${(int)com} of ${(int)tot}".toString() - // Task hash - make clickable hyperlink to work dir when available (and cleanup not enabled) + // Task hash, eg: [fa/71091a] + // make clickable hyperlink to work dir inferred from session workDir and task hash final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh term.a(Attribute.INTENSITY_FAINT).a('[').reset() term.fg(Color.BLUE).a(hashDisplay).reset()