Repository: jenkinsci/ansicolor-plugin (java)

Issue:
Can't colorize a part of of a Jenkins log.
Please provide precise details of the issue you detected.

### Test environment

- Jenkins Version: 2.222.3
- AnsiColor Version: 0.6.3
- Hostsystem: Linux

### Expected behavior
What did you expect the plugin to do?

Colorize the ansiColor wrapped shell script output ONLY.

### Actual behavior
What did the plugin do instead?

The entire log is colorized.

### Steps to reproduce the behavior

Create a pipeline job with the below content:

```
pipeline {
    agent { node { label "master" } }
    stages {
        stage('job') {
            steps {
                script {
                    echo "Before Ansi Color Wrap."
                    ansiColor('vga') {
                        sh "ls -la"
                    }
                    echo "After Ansi Color Wrap."
                }
            }
        }
    }
}
```

Pull request:
180 eagerness off
This fixes #180

My initial idea of storing the meant-to-be-processed log line numbers would become more complicated than I initially anticipated so I changed it in favor of indicating only the log lines where the `ansicolor-plugin` should either begin or finish processing.

Main bullet points:
- Every time an `ansiColor` step is invoked a `ColorizedAction` step is added to the run context (to indicate starting and stopping)
- At the same time an html-invisible (`div`) marker is injected into the log output to allow `ColorConsoleAnnotator` to recognize when it should do something
- `ColorConsoleAnnotator` inspects each line and if it encounters the marker (matched with a corresponding action) - it either starts/stops its main functionality or simply continues assuming previously configured settings


All tests are passing (including my manual ones) but as there are not that many of them, I would appreciate another opinion.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecadaf5..75e345d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,10 @@
-0.6.4 (Next)
+0.7.0 (Next)
 ============
 
 * [#173](https://github.com/jenkinsci/ansicolor-plugin/pull/173): Fix for SGR Normal intensity not handled correctly - [@tszmytka](https://github.com/tszmytka).
 * [#176](https://github.com/jenkinsci/ansicolor-plugin/pull/176): Ensure extended color SGRs are recognized correctly - [@tszmytka](https://github.com/tszmytka).
 * [#156](https://github.com/jenkinsci/ansicolor-plugin/pull/156): Improve documentation - [@tszmytka](https://github.com/tszmytka).
+* [#181](https://github.com/jenkinsci/ansicolor-plugin/pull/181): Re-implement support for multiple ansiColor steps within one pipeline script - [@tszmytka](https://github.com/tszmytka).
 * Your contribution here.
 
 0.6.3 (02/24/2020)
diff --git a/README.md b/README.md
index c6de7d8..1ab7c3c 100644
--- a/README.md
+++ b/README.md
@@ -23,25 +23,47 @@ java -jar jenkins-cli.jar install-plugin ansicolor
 ```
 # Enable
 
-![enable](images/ansicolor-enable.png "Enable AnsiColor")
-
-## Using in pipeline workflows
+## Pipeline
 
-The build wrapper can be used to colorize the output of steps in a pipeline build (plugin formally known as workflows).
+The build wrapper can be used to colorize the output of all steps in a pipeline build (plugin formally known as workflows).
 The example below shows how to use it.
 
+```groovy
+pipeline {
+    agent any
+    options {
+        ansiColor('xterm')
+    }
+    stages {
+        stage('Build') {
+            steps {
+                sh 'something that outputs ansi colored stuff'
+            }
+        }
+    }
+}
+```
+
+or
+
 ```groovy
 wrap([$class: 'AnsiColorBuildWrapper', 'colorMapName': 'XTerm']) {
   sh 'something that outputs ansi colored stuff'
 }
 ```
 
-Also, the following pipeline syntax can be used without allocating a node and a separate build wrapper.
+Also, the following pipeline syntax can be used without allocating a node or a separate build wrapper.
 
 ```groovy
 ansiColor('xterm') {
   echo 'something that outputs ansi colored stuff'
 }
+
+// multiple ansiColor steps within one pipeline are also supported
+echo 'this will be rendered as-is'
+ansiColor('vga') {
+  echo 'another ansi colored command'
+}
 ```
 
 The `TERM` environment variable will be set to the provided name inside the block scope.
@@ -53,6 +75,10 @@ ansiColor('xterm') {
 }
 ```
 
+## UI defined job
+
+![enable](images/ansicolor-enable.png "Enable AnsiColor")
+
 # Color!
 
 ![color](images/ansicolor.png "Color with AnsiColor")
diff --git a/pom.xml b/pom.xml
index aba716c..87203d9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -77,6 +77,18 @@
       <artifactId>workflow-basic-steps</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <version>3.16.1</version>
+      <scope>test</scope>
+    </dependency>
+
   </dependencies>
   <dependencyManagement>
     <dependencies>
diff --git a/src/main/java/hudson/plugins/ansicolor/AnsiColorBuildWrapper.java b/src/main/java/hudson/plugins/ansicolor/AnsiColorBuildWrapper.java
index 197c8d5..f54e6e1 100644
--- a/src/main/java/hudson/plugins/ansicolor/AnsiColorBuildWrapper.java
+++ b/src/main/java/hudson/plugins/ansicolor/AnsiColorBuildWrapper.java
@@ -30,6 +30,8 @@ import hudson.Launcher;
 import hudson.model.TaskListener;
 import hudson.model.AbstractProject;
 import hudson.model.Run;
+import hudson.plugins.ansicolor.action.ActionNote;
+import hudson.plugins.ansicolor.action.ColorizedAction;
 import hudson.tasks.BuildWrapper;
 import hudson.tasks.BuildWrapperDescriptor;
 import hudson.util.FormValidation;
@@ -78,9 +80,10 @@ public final class AnsiColorBuildWrapper extends SimpleBuildWrapper implements S
     }
 
     @Override
-    public void setUp(Context context, Run<?, ?> build, FilePath workspace, Launcher launcher, TaskListener listener,
-            EnvVars initialEnvironment) throws IOException, InterruptedException {
-        build.replaceAction(new ColorizedAction(colorMapName));
+    public void setUp(Context context, Run<?, ?> build, FilePath workspace, Launcher launcher, TaskListener listener, EnvVars initialEnvironment) throws IOException, InterruptedException {
+        final ColorizedAction action = new ColorizedAction(colorMapName, ColorizedAction.Command.START);
+        build.replaceAction(action);
+        listener.annotate(new ActionNote(action));
     }
 
     /**
diff --git a/src/main/java/hudson/plugins/ansicolor/AnsiColorStep.java b/src/main/java/hudson/plugins/ansicolor/AnsiColorStep.java
index bdc88aa..34954d9 100644
--- a/src/main/java/hudson/plugins/ansicolor/AnsiColorStep.java
+++ b/src/main/java/hudson/plugins/ansicolor/AnsiColorStep.java
@@ -1,25 +1,22 @@
 package hudson.plugins.ansicolor;
 
 import hudson.Extension;
+import hudson.model.Run;
+import hudson.model.TaskListener;
 import hudson.plugins.ansicolor.AnsiColorBuildWrapper.DescriptorImpl;
+import hudson.plugins.ansicolor.action.ActionNote;
+import hudson.plugins.ansicolor.action.ColorizedAction;
 import hudson.util.ListBoxModel;
-
-import java.util.Collections;
-
-
 import jenkins.model.Jenkins;
-
-import org.jenkinsci.plugins.workflow.steps.AbstractStepExecutionImpl;
-import org.jenkinsci.plugins.workflow.steps.BodyExecutionCallback;
-import org.jenkinsci.plugins.workflow.steps.EnvironmentExpander;
-import org.jenkinsci.plugins.workflow.steps.StepContext;
+import org.jenkinsci.plugins.workflow.steps.*;
 import org.kohsuke.stapler.DataBoundConstructor;
 
-import hudson.model.Run;
+import javax.annotation.Nonnull;
+import java.io.IOException;
+import java.util.Collections;
 import java.util.Set;
-import org.jenkinsci.plugins.workflow.steps.Step;
-import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
-import org.jenkinsci.plugins.workflow.steps.StepExecution;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 /**
  * Custom pipeline step that can be used without a node and build wrapper.
@@ -45,7 +42,7 @@ public class AnsiColorStep extends Step {
     }
 
     private static DescriptorImpl getWrapperDescriptor() {
-        return Jenkins.getActiveInstance().getDescriptorByType(DescriptorImpl.class);
+        return Jenkins.get().getDescriptorByType(DescriptorImpl.class);
     }
 
     @Override
@@ -73,15 +70,14 @@ public class AnsiColorStep extends Step {
         @Override
         public boolean start() throws Exception {
             StepContext context = getContext();
-            context.get(Run.class).replaceAction(new ColorizedAction(colorMapName));
             EnvironmentExpander currentEnvironment = context.get(EnvironmentExpander.class);
             EnvironmentExpander terminalEnvironment = EnvironmentExpander.constant(Collections.singletonMap("TERM", colorMapName));
             context.newBodyInvoker()
-                   .withContext(EnvironmentExpander.merge(currentEnvironment, terminalEnvironment))
-                                                   .withCallback(BodyExecutionCallback.wrap(context)).start();
+                .withContext(EnvironmentExpander.merge(currentEnvironment, terminalEnvironment))
+                .withCallback(new AnsiColorExecution(colorMapName))
+                .start();
             return false;
         }
-
     }
 
     /**
@@ -90,6 +86,7 @@ public class AnsiColorStep extends Step {
     @Extension(optional = true)
     public static class StepDescriptorImpl extends StepDescriptor {
 
+        @Nonnull
         @Override
         public String getDisplayName() {
             return Messages.DisplayName();
@@ -119,6 +116,46 @@ public class AnsiColorStep extends Step {
         public Set<? extends Class<?>> getRequiredContext() {
             return Collections.singleton(Run.class);
         }
+    }
+
+    private static class AnsiColorExecution extends BodyExecutionCallback {
+        private static final Logger LOGGER = Logger.getLogger(AnsiColorExecution.class.getName());
+
+        private final String colorMapName;
+
+        public AnsiColorExecution(String colorMapName) {
+            this.colorMapName = colorMapName;
+        }
 
+        @Override
+        public void onStart(StepContext context) {
+            issueAction(context, new ColorizedAction(colorMapName, ColorizedAction.Command.START));
+            super.onStart(context);
+        }
+
+        @Override
+        public void onSuccess(StepContext context, Object result) {
+            issueAction(context, new ColorizedAction(colorMapName, ColorizedAction.Command.STOP));
+            context.onSuccess(result);
+        }
+
+        @Override
+        public void onFailure(StepContext context, Throwable t) {
+            issueAction(context, new ColorizedAction(colorMapName, ColorizedAction.Command.STOP));
+            context.onFailure(t);
+        }
+
+        private void issueAction(StepContext context, ColorizedAction action) {
+            try {
+                final TaskListener taskListener = context.get(TaskListener.class);
+                final Run<?, ?> run = context.get(Run.class);
+                if (taskListener != null && run != null) {
+                    run.addAction(action);
+                    taskListener.annotate(new ActionNote(action));
+                }
+            } catch (IOException | InterruptedException e) {
+                LOGGER.log(Level.WARNING, "Could not annotate. Ansicolor plugin will not work correctly.", e);
+            }
+        }
     }
 }
diff --git a/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java b/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java
index 0ec61c7..aa078d8 100644
--- a/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java
+++ b/src/main/java/hudson/plugins/ansicolor/ColorConsoleAnnotator.java
@@ -26,18 +26,11 @@ package hudson.plugins.ansicolor;
 
 import hudson.Extension;
 import hudson.MarkupText;
-import hudson.Util;
 import hudson.console.ConsoleAnnotator;
 import hudson.console.ConsoleAnnotatorFactory;
 import hudson.model.Queue;
 import hudson.model.Run;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import javax.annotation.CheckForNull;
-import javax.annotation.Nonnull;
+import hudson.plugins.ansicolor.action.ColorizedAction;
 import jenkins.model.Jenkins;
 import org.apache.commons.io.output.CountingOutputStream;
 import org.apache.commons.io.output.NullOutputStream;
@@ -45,6 +38,14 @@ import org.apache.commons.lang.StringEscapeUtils;
 import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
 import org.jenkinsci.plugins.workflow.graph.FlowNode;
 
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
 /**
  * Applies ANSI coloration to log files where requested.
  */
@@ -54,43 +55,53 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> {
 
     private static final long serialVersionUID = 1;
 
-    private final @CheckForNull String colorMapName;
-    private final @Nonnull List<AnsiAttributeElement> openTags;
+    private static final Factory FACTORY = new Factory();
 
-    private ColorConsoleAnnotator(String colorMapName, @Nonnull List<AnsiAttributeElement> openTags) {
-        this.colorMapName = colorMapName;
-        this.openTags = openTags;
-        LOGGER.log(Level.FINE, "creating annotator with colorMapName={0} openTags={1}", new Object[] { colorMapName, openTags });
-    }
+    private final String defaultColorMapName;
+
+    @CheckForNull
+    private String colorMapName;
 
-    ColorConsoleAnnotator() {
-        this(null, Collections.emptyList());
+    @Nonnull
+    private List<AnsiAttributeElement> openTags = Collections.emptyList();
+
+    private ColorConsoleAnnotator(String defaultColorMapName) {
+        this.defaultColorMapName = defaultColorMapName;
     }
 
     @Override
-    public ConsoleAnnotator<Object> annotate(@Nonnull Object context, MarkupText text) {
-        String actualColorMapName;
+    public ConsoleAnnotator<Object> annotate(@Nonnull Object context, @Nonnull MarkupText text) {
+        final ColorizedAction colorizedAction = ColorizedAction.parseAction(text, runOf(context));
+        switch (colorizedAction.getCommand()) {
+            case START:
+                colorMapName = colorizedAction.getColorMapName();
+                break;
+            case STOP:
+                return FACTORY.newInstance(context);
+            default:
+                if (colorMapName == null) {
+                    colorMapName = defaultColorMapName;
+                }
+                break;
+        }
         if (colorMapName == null) {
-            actualColorMapName = colorMapNameFor(context);
-            if (actualColorMapName == null) {
-                return this;
-            }
-        } else {
-            actualColorMapName = colorMapName;
+            return this;
         }
+
         String s = text.getText();
         List<AnsiAttributeElement> nextOpenTags = openTags;
-        AnsiColorMap colorMap = Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getColorMap(actualColorMapName);
+        AnsiColorMap colorMap = Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getColorMap(colorMapName);
         if (s.indexOf('\u001B') != -1 || !openTags.isEmpty() || colorMap.getDefaultBackground() != null || colorMap.getDefaultForeground() != null) {
             CountingOutputStream outgoing = new CountingOutputStream(new NullOutputStream());
             class EmitterImpl implements AnsiAttributeElement.Emitter {
                 CountingOutputStream incoming;
                 int adjustment;
                 int lastPoint = -1; // multiple HTML tags may be emitted for one control sequence
+
                 @Override
                 public void emitHtml(@Nonnull String html) {
                     final int inCount = getIncomingCount();
-                    LOGGER.log(Level.FINEST, "emitting {0} @{1}/{2}", new Object[] { html, inCount, s.length() });
+                    LOGGER.log(Level.FINEST, "emitting {0} @{1}/{2}", new Object[]{html, inCount, s.length()});
                     text.addMarkup(inCount, html);
                     hideIfNeeded(inCount, "");
                 }
@@ -115,7 +126,7 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> {
                         // If openTags is not empty, but there are no escape sequences directly on this line, or if we
                         // are emitting closing tags when closing the stream, there is nothing to hide.
                         if (hide != 0) {
-                            LOGGER.log(Level.FINEST, "hiding {0} @{1}{2}", new Object[] { hide, outCount, msg});
+                            LOGGER.log(Level.FINEST, "hiding {0} @{1}{2}", new Object[]{hide, outCount, msg});
                             text.addMarkup(outCount, outCount + hide, "<!--", "-->");
                             adjustment += hide;
                         }
@@ -130,8 +141,10 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> {
             EmitterImpl emitter = new EmitterImpl();
             // We need to reopen tags that were still open at the end of the previous line so the stream's state is
             // correct in case those tags are closed in the middle of this line.
-            try (AnsiHtmlOutputStream ansiOs = new AnsiHtmlOutputStream(outgoing, colorMap, emitter, openTags);
-                    CountingOutputStream incoming = new CountingOutputStream(ansiOs)) {
+            try (
+                AnsiHtmlOutputStream ansiOs = new AnsiHtmlOutputStream(outgoing, colorMap, emitter, openTags);
+                CountingOutputStream incoming = new CountingOutputStream(ansiOs)
+            ) {
                 emitter.incoming = incoming;
                 /*
                  * We only use AnsiHtmlOutputStream for its calls to Emitter.emitHtml when it encounters ANSI escape
@@ -165,15 +178,15 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> {
             }
             LOGGER.finer(() -> "\"" + StringEscapeUtils.escapeJava(s) + "\" → \"" + StringEscapeUtils.escapeJava(text.toString(true)) + "\"");
         }
-        return openTags == nextOpenTags
-                ? this
-                : new ColorConsoleAnnotator(actualColorMapName, nextOpenTags);
+        openTags = nextOpenTags;
+        return this;
     }
 
-    private static @CheckForNull Run<?, ?> runOf(Object context) {
+    @CheckForNull
+    private static Run<?, ?> runOf(Object context) {
         LOGGER.log(Level.FINE, "context={0}", context);
         if (context instanceof Run) {
-            return (Run) context;
+            return (Run<?, ?>) context;
         } else if (Jenkins.get().getPlugin("workflow-api") != null && context instanceof FlowNode) {
             FlowNode node = (FlowNode) context;
             FlowExecutionOwner owner = node.getExecution().getOwner();
@@ -185,32 +198,19 @@ final class ColorConsoleAnnotator extends ConsoleAnnotator<Object> {
                     LOGGER.log(Level.WARNING, null, x);
                 }
                 if (exec instanceof Run) {
-                    return (Run) exec;
+                    return (Run<?, ?>) exec;
                 }
             }
         }
         return null;
     }
 
-    private static @CheckForNull String colorMapNameFor(Object context) {
-        Run<?, ?> run = runOf(context);
-        if (run != null) {
-            ColorizedAction ca = run.getAction(ColorizedAction.class);
-            if (ca != null) {
-                return ca.colorMapName != null ? ca.colorMapName : AnsiColorMap.DefaultName;
-            }
-        }
-        return Util.fixEmpty(Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getGlobalColorMapName());
-    }
-
     @Extension
     public static final class Factory extends ConsoleAnnotatorFactory<Object> {
 
         @Override
         public ConsoleAnnotator<Object> newInstance(Object context) {
-            return new ColorConsoleAnnotator();
+            return new ColorConsoleAnnotator(Jenkins.get().getDescriptorByType(AnsiColorBuildWrapper.DescriptorImpl.class).getGlobalColorMapName());
         }
-
     }
-
 }
diff --git a/src/main/java/hudson/plugins/ansicolor/ColorizedAction.java b/src/main/java/hudson/plugins/ansicolor/ColorizedAction.java
deleted file mode 100644
index 9c38005..0000000
--- a/src/main/java/hudson/plugins/ansicolor/ColorizedAction.java
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * The MIT License
- *
- * Copyright 2018 CloudBees, Inc.
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
-package hudson.plugins.ansicolor;
-
-import hudson.model.InvisibleAction;
-
-/**
- * Marker for the fact that a build used colorization.
- * Note that the specific log span(s) are ignored.
- */
-final class ColorizedAction extends InvisibleAction {
-
-    final String colorMapName;
-
-    ColorizedAction(String colorMapName) {
-        this.colorMapName = colorMapName;
-    }
-
-}
diff --git a/src/main/java/hudson/plugins/ansicolor/action/ActionNote.java b/src/main/java/hudson/plugins/ansicolor/action/ActionNote.java
new file mode 100644
index 0000000..61a98ad
--- /dev/null
+++ b/src/main/java/hudson/plugins/ansicolor/action/ActionNote.java
@@ -0,0 +1,27 @@
+package hudson.plugins.ansicolor.action;
+
+import hudson.MarkupText;
+import hudson.console.ConsoleAnnotator;
+import hudson.console.ConsoleNote;
+import hudson.model.Run;
+
+/**
+ * Marker note accompanying a ColorizedAction showing where an action needs to take place
+ */
+public class ActionNote extends ConsoleNote<Run<?, ?>> {
+    static final String TAG_ACTION_BEGIN = "<div data-ansicolor-action=";
+    private static final String TAG_ACTION_ID_TEMPLATE = "\"%s\"";
+    static final String TAG_ACTION_END = "></div>";
+
+    private final String actionId;
+
+    public ActionNote(ColorizedAction action) {
+        actionId = action.getId().toString();
+    }
+
+    @Override
+    public ConsoleAnnotator<Run<?, ?>> annotate(Run<?, ?> context, MarkupText text, int charPos) {
+        text.addMarkup(charPos, TAG_ACTION_BEGIN + String.format(TAG_ACTION_ID_TEMPLATE, actionId) + TAG_ACTION_END);
+        return null;
+    }
+}
diff --git a/src/main/java/hudson/plugins/ansicolor/action/ColorizedAction.java b/src/main/java/hudson/plugins/ansicolor/action/ColorizedAction.java
new file mode 100644
index 0000000..f8aa1f0
--- /dev/null
+++ b/src/main/java/hudson/plugins/ansicolor/action/ColorizedAction.java
@@ -0,0 +1,83 @@
+/*
+ * The MIT License
+ *
+ * Copyright 2018 CloudBees, Inc.
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+package hudson.plugins.ansicolor.action;
+
+import hudson.MarkupText;
+import hudson.model.InvisibleAction;
+import hudson.model.Run;
+import hudson.plugins.ansicolor.AnsiColorMap;
+
+import java.util.UUID;
+
+import static hudson.plugins.ansicolor.action.ActionNote.TAG_ACTION_BEGIN;
+
+/**
+ * Action for issuing commands to ColorConsoleAnnotator
+ */
+public class ColorizedAction extends InvisibleAction {
+    static final ColorizedAction CONTINUE = new ColorizedAction("", Command.CONTINUE);
+
+    private final UUID id;
+
+    private final String colorMapName;
+
+    private final Command command;
+
+    public enum Command {
+        START,
+        STOP,
+        CONTINUE
+    }
+
+    public ColorizedAction(String colorMapName, Command command) {
+        id = UUID.randomUUID();
+        this.colorMapName = colorMapName == null || colorMapName.isEmpty() ? AnsiColorMap.DefaultName : colorMapName;
+        this.command = command;
+    }
+
+    public UUID getId() {
+        return id;
+    }
+
+    public String getColorMapName() {
+        return colorMapName;
+    }
+
+    public Command getCommand() {
+        return command;
+    }
+
+    public static ColorizedAction parseAction(MarkupText text, Run<?, ?> run) {
+        final String line = text.toString(false);
+        final int actionIdOffset = line.indexOf(TAG_ACTION_BEGIN);
+        if (actionIdOffset != -1) {
+            final int from = actionIdOffset + TAG_ACTION_BEGIN.length() + 1;
+            final int to = line.indexOf("\"", from);
+            final String id = line.substring(from, to);
+            return run.getActions(ColorizedAction.class).stream().filter(a -> id.equals(a.getId().toString())).findAny().orElse(CONTINUE);
+        }
+        return CONTINUE;
+    }
+}


Test patch:
diff --git a/src/test/java/hudson/plugins/ansicolor/AnsiColorStepTest.java b/src/test/java/hudson/plugins/ansicolor/AnsiColorStepTest.java
index d260433..bfcbb7e 100644
--- a/src/test/java/hudson/plugins/ansicolor/AnsiColorStepTest.java
+++ b/src/test/java/hudson/plugins/ansicolor/AnsiColorStepTest.java
@@ -1,11 +1,5 @@
 package hudson.plugins.ansicolor;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-import java.io.StringWriter;
-import java.util.logging.Level;
-
 import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
 import org.jenkinsci.plugins.workflow.job.WorkflowJob;
 import org.jenkinsci.plugins.workflow.job.WorkflowRun;
@@ -14,9 +8,19 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runners.model.Statement;
 import org.jvnet.hudson.test.BuildWatcher;
+import org.jvnet.hudson.test.Issue;
 import org.jvnet.hudson.test.LoggerRule;
 import org.jvnet.hudson.test.RestartableJenkinsRule;
 
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.logging.Level;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
 public class AnsiColorStepTest {
 
     public AnsiColorStepTest() {
@@ -49,18 +53,77 @@ public class AnsiColorStepTest {
             public void evaluate() throws Throwable {
                 WorkflowJob p = story.j.jenkins.createProject(WorkflowJob.class, "p");
                 p.setDefinition(new CpsFlowDefinition(
-                        "ansiColor('xterm') {\n"
-                                + "  echo 'The following word is supposed to be \\u001B[31mred\\u001B[0m'\n"
-                                + " echo \"TERM=${env.TERM}\""
-                                + "}"
-                        , true));
+                    "ansiColor('xterm') {\n"
+                        + "  echo 'The following word is supposed to be \\u001B[31mred\\u001B[0m'\n"
+                        + " echo \"TERM=${env.TERM}\""
+                        + "}"
+                    , true));
                 WorkflowRun run = story.j.assertBuildStatusSuccess(p.scheduleBuild2(0));
                 StringWriter writer = new StringWriter();
                 p.getLastBuild().getLogText().writeHtmlTo(0L, writer);
                 String html = writer.toString();
                 story.j.assertLogContains("TERM=xterm", run);
-                assertTrue("Failed to match color attribute in following HTML log output:\n" + html,
-                        html.replaceAll("<!--.+?-->", "").matches("(?s).*<span style=\"color: #CD0000;\">red</span>.*"));
+                assertTrue(
+                    "Failed to match color attribute in following HTML log output:\n" + html,
+                    html.replaceAll("<!--.+?-->", "").matches("(?s).*<span style=\"color: #CD0000;\">red</span>.*")
+                );
+            }
+        });
+    }
+
+    @Issue("180")
+    @Test
+    public void canRenderMultiplePipelineSteps() {
+        final String script = "echo '\033[32mbefore\033[0m'\n" +
+            "ansiColor('vga') {\n" +
+            "    echo '\033[32mstep one\033[0m'\n" +
+            "}\n" +
+            "echo '\033[32mbetween two steps\033[0m'\n" +
+            "ansiColor('xterm') {\n" +
+            "    echo '\033[32mstep two\033[0m'\n" +
+            "}\n" +
+            "echo '\033[32mafter step two\033[0m'\n" +
+            "ansiColor {\n" +
+            "    echo '\033[32mstep three\033[0m'\n" +
+            "}\n" +
+            "echo '\033[32mafter step three\033[0m'";
+
+        assertOutputOnRunningPipeline(
+            Arrays.asList(
+                "\033[32mbefore\033[0m",
+                "<span style=\"color: #00AA00;\">step one</span>",
+                "\033[32mbetween two steps\033[0m",
+                "<span style=\"color: #00CD00;\">step two</span>",
+                "\033[32mafter step two\033[0m",
+                "<span style=\"color: #00CD00;\">step three</span>",
+                "\033[32mafter step three\033[0m"
+            ),
+            Arrays.asList(
+                "<span style=\"color: #00AA00;\">before</span>",
+                "\033[32mstep one\033[0m",
+                "<span style=\"color: #00AA00;\">between two steps</span>",
+                "\033[32mstep two\033[0m",
+                "<span style=\"color: #00AA00;\">after step two</span>",
+                "\033[32mstep three\033[0m",
+                "<span style=\"color: #00AA00;\">after step three</span>"
+            ),
+            script
+        );
+    }
+
+    private void assertOutputOnRunningPipeline(Collection<String> expectedOutput, Collection<String> notExpectedOutput, String pipelineScript) {
+        story.addStep(new Statement() {
+
+            @Override
+            public void evaluate() throws Throwable {
+                final WorkflowJob project = story.j.jenkins.createProject(WorkflowJob.class, "p");
+                project.setDefinition(new CpsFlowDefinition(pipelineScript, true));
+                story.j.assertBuildStatusSuccess(project.scheduleBuild2(0));
+                StringWriter writer = new StringWriter();
+                assertTrue(project.getLastBuild().getLogText().writeHtmlTo(0, writer) > 0);
+                final String html = writer.toString().replaceAll("<!--.+?-->", "");
+                assertThat(html).contains(expectedOutput);
+                assertThat(html).doesNotContain(notExpectedOutput);
             }
         });
     }
diff --git a/src/test/java/hudson/plugins/ansicolor/action/ActionNoteTest.java b/src/test/java/hudson/plugins/ansicolor/action/ActionNoteTest.java
new file mode 100644
index 0000000..11ed4ba
--- /dev/null
+++ b/src/test/java/hudson/plugins/ansicolor/action/ActionNoteTest.java
@@ -0,0 +1,44 @@
+package hudson.plugins.ansicolor.action;
+
+import hudson.MarkupText;
+import hudson.model.FreeStyleBuild;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.UUID;
+
+import static hudson.plugins.ansicolor.action.ActionNote.TAG_ACTION_BEGIN;
+import static hudson.plugins.ansicolor.action.ActionNote.TAG_ACTION_END;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ActionNoteTest {
+    private static final UUID UUID = java.util.UUID.randomUUID();
+
+    private ActionNote actionNote;
+
+    @Mock
+    private ColorizedAction colorizedAction;
+
+    @Mock
+    private FreeStyleBuild buildRun;
+
+    @Before
+    public void setUp() throws Exception {
+        when(colorizedAction.getId()).thenReturn(UUID);
+        actionNote = new ActionNote(colorizedAction);
+    }
+
+    @Test
+    public void canAnnotate() {
+        final MarkupText markupText = new MarkupText("abc123");
+        assertNull(actionNote.annotate(buildRun, markupText, 4));
+        final String output = markupText.toString(false);
+        assertTrue(output.contains(TAG_ACTION_BEGIN + "\"" + UUID + "\"" + TAG_ACTION_END));
+    }
+}
diff --git a/src/test/java/hudson/plugins/ansicolor/action/ColorizedActionTest.java b/src/test/java/hudson/plugins/ansicolor/action/ColorizedActionTest.java
new file mode 100644
index 0000000..ad5853f
--- /dev/null
+++ b/src/test/java/hudson/plugins/ansicolor/action/ColorizedActionTest.java
@@ -0,0 +1,68 @@
+package hudson.plugins.ansicolor.action;
+
+import hudson.MarkupText;
+import hudson.model.FreeStyleBuild;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.Arrays;
+
+import static hudson.plugins.ansicolor.action.ActionNote.TAG_ACTION_BEGIN;
+import static hudson.plugins.ansicolor.action.ActionNote.TAG_ACTION_END;
+import static hudson.plugins.ansicolor.action.ColorizedAction.CONTINUE;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ColorizedActionTest {
+    private static final ColorizedAction ACTION_0 = new ColorizedAction("map0", ColorizedAction.Command.START);
+    private static final ColorizedAction ACTION_1 = new ColorizedAction("map1", ColorizedAction.Command.CONTINUE);
+    private static final ColorizedAction ACTION_2 = new ColorizedAction("map2", ColorizedAction.Command.STOP);
+
+    private ColorizedAction colorizedAction;
+
+    @Mock
+    private FreeStyleBuild buildRun;
+
+    @Before
+    public void setUp() throws Exception {
+        when(buildRun.getActions(eq(ColorizedAction.class))).thenReturn(Arrays.asList(
+            ACTION_0,
+            ACTION_1,
+            ACTION_2
+        ));
+        colorizedAction = new ColorizedAction("vga", ColorizedAction.Command.START);
+    }
+
+    @Test
+    public void canInitAndGetProperties() {
+        assertNotNull(colorizedAction.getId());
+        assertEquals("vga", colorizedAction.getColorMapName());
+        assertEquals(ColorizedAction.Command.START, colorizedAction.getCommand());
+    }
+
+    @Test
+    public void canParseAction() {
+        final MarkupText markupText = new MarkupText("Log line");
+        markupText.addMarkup(0, TAG_ACTION_BEGIN + "\"" + ACTION_1.getId() + "\"" + TAG_ACTION_END);
+        assertEquals(ACTION_1, ColorizedAction.parseAction(markupText, buildRun));
+    }
+
+    @Test
+    public void willReturnDefaultIfLogDoesntContainAnnotation() {
+        final MarkupText markupText = new MarkupText("Log line");
+        assertEquals(CONTINUE, ColorizedAction.parseAction(markupText, buildRun));
+    }
+
+    @Test
+    public void willReturnDefaultIfLogAnnotationPointsToNonexistingAction() {
+        final MarkupText markupText = new MarkupText("Log line");
+        markupText.addMarkup(0, TAG_ACTION_BEGIN + "\"identifier_not_in_actions\"" + TAG_ACTION_END);
+        assertEquals(CONTINUE, ColorizedAction.parseAction(markupText, buildRun));
+    }
+}