Repository: docker/compose

Issue:
[BUG] runing a service builds its depends-ons twice when pull policy is build
### Description

Please see the steps to reproduce

### Steps To Reproduce

With a `compose.yaml` like this:
``` yaml
services:
  nginx:
    pull_policy: build
    build:
      dockerfile_inline: |
          FROM nginx
          # this is used as "marker" of time spent during builds
          ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo
    healthcheck:
      test: curl -fs localhost
      start_interval: 1s
      start_period: 10s

  curl:
    image: alpine/curl
    depends_on:
      nginx:
        condition: service_healthy
    command: curl -fsI nginx
```

`docker compose run curl` will produce the following output:
```console
[+] Building 5.7s (10/10) FINISHED                                                                                                                                                            
 => [internal] load local bake definitions                                                                                                                                               0.0s
 => => reading from stdin 605B                                                                                                                                                           0.0s
 => [internal] load build definition from Dockerfile                                                                                                                                     0.0s
 => => transferring dockerfile: 186B                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/nginx:latest                                                                                                                          1.4s
 => [auth] library/nginx:pull token for registry-1.docker.io                                                                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                                                        0.0s
 => => transferring context: 2B                                                                                                                                                          0.0s
 => [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                               3.9s
 => [1/2] FROM docker.io/library/nginx:latest@sha256:029d4461bd98f124e531380505ceea2072418fdf28752aa73b7b273ba3048903                                                                    0.0s
 => CACHED [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                        0.0s
 => exporting to image                                                                                                                                                                   0.0s
 => => exporting layers                                                                                                                                                                  0.0s
 => => writing image sha256:94163b15c51df0859e7f02f29c07aa61618af9de589b579b1c73c951c9772ccd                                                                                             0.0s
 => => naming to docker.io/library/run-nginx                                                                                                                                             0.0s
 => resolving provenance for metadata file                                                                                                                                               0.0s
[+] Creating 3/3
 ✔ run-nginx              Built                                                                                                                                                          0.0s 
 ✔ Network run_default    Created                                                                                                                                                        0.1s 
 ✔ Container run-nginx-1  Created                                                                                                                                                        0.1s 
[+] Running 1/1
 ✔ Container run-nginx-1  Started                                                                                                                                                        0.3s 
[+] Building 3.6s (9/9) FINISHED                                                                                                                                                              
 => [internal] load local bake definitions                                                                                                                                               0.0s
 => => reading from stdin 605B                                                                                                                                                           0.0s
 => [internal] load build definition from Dockerfile                                                                                                                                     0.0s
 => => transferring dockerfile: 186B                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/nginx:latest                                                                                                                          0.3s
 => [internal] load .dockerignore                                                                                                                                                        0.0s
 => => transferring context: 2B                                                                                                                                                          0.0s
 => [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                               2.9s
 => [1/2] FROM docker.io/library/nginx:latest@sha256:029d4461bd98f124e531380505ceea2072418fdf28752aa73b7b273ba3048903                                                                    0.0s
 => CACHED [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                        0.0s
 => exporting to image                                                                                                                                                                   0.0s
 => => exporting layers                                                                                                                                                                  0.0s
 => => writing image sha256:94163b15c51df0859e7f02f29c07aa61618af9de589b579b1c73c951c9772ccd                                                                                             0.0s
 => => naming to docker.io/library/run-nginx                                                                                                                                             0.0s
 => resolving provenance for metadata file                                                                                                                                               0.0s
HTTP/1.1 200 OK
Server: nginx/1.29.2
Date: Tue, 28 Oct 2025 09:22:46 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Tue, 07 Oct 2025 17:04:07 GMT
Connection: keep-alive
ETag: "68e54807-267"
Accept-Ranges: bytes
```

beside the two building stages that are quite visible, adding git repos to an image becomes particularly ... lengthy, with a first `[2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo 3.9s` followed by a second `[2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo 2.9s` (and for other repos times could be a lot bigger)

### Compose Version

```Text
Docker Compose version v2.40.2
```

### Docker Environment

```Text

```

### Anything else?

a `docker compose up curl` does not lead to two builds:
```console
[+] Building 5.6s (10/10) FINISHED                                                                                                                                                            
 => [internal] load local bake definitions                                                                                                                                               0.0s
 => => reading from stdin 605B                                                                                                                                                           0.0s
 => [internal] load build definition from Dockerfile                                                                                                                                     0.0s
 => => transferring dockerfile: 186B                                                                                                                                                     0.0s
 => [internal] load metadata for docker.io/library/nginx:latest                                                                                                                          1.3s
 => [auth] library/nginx:pull token for registry-1.docker.io                                                                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                                                        0.0s
 => => transferring context: 2B                                                                                                                                                          0.0s
 => [1/2] FROM docker.io/library/nginx:latest@sha256:029d4461bd98f124e531380505ceea2072418fdf28752aa73b7b273ba3048903                                                                    0.0s
 => [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                               3.9s
 => CACHED [2/2] ADD --link --keep-git-dir https://github.com/kubernetes/kubernetes.git /big-repo                                                                                        0.0s
 => exporting to image                                                                                                                                                                   0.0s
 => => exporting layers                                                                                                                                                                  0.0s
 => => writing image sha256:94163b15c51df0859e7f02f29c07aa61618af9de589b579b1c73c951c9772ccd                                                                                             0.0s
 => => naming to docker.io/library/run-nginx                                                                                                                                             0.0s
 => resolving provenance for metadata file                                                                                                                                               0.0s
[+] Running 4/4
 ✔ run-nginx              Built                                                                                                                                                          0.0s 
 ✔ Network run_default    Created                                                                                                                                                        0.1s 
 ✔ Container run-nginx-1  Created                                                                                                                                                        0.1s 
 ✔ Container run-curl-1   Created                                                                                                                                                        0.1s 
Attaching to curl-1
curl-1  | HTTP/1.1 200 OK
curl-1  | Server: nginx/1.29.2
curl-1  | Date: Tue, 28 Oct 2025 09:34:23 GMT
curl-1  | Content-Type: text/html
curl-1  | Content-Length: 615
curl-1  | Last-Modified: Tue, 07 Oct 2025 17:04:07 GMT
curl-1  | Connection: keep-alive
curl-1  | ETag: "68e54807-267"
curl-1  | Accept-Ranges: bytes
curl-1  | 
curl-1 exited with code 0
```

Hints from issue comments:
@ndeloof: fixed by https://github.com/docker/compose/pull/13325

Solution patch:
diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go
index 6ebdabb3..7ebaa0ca 100644
--- a/cmd/compose/compose.go
+++ b/cmd/compose/compose.go
@@ -508,29 +508,42 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
 				ui.Mode = ui.ModeTTY
 			}
 
+			var ep ui.EventProcessor
 			switch opts.Progress {
 			case "", ui.ModeAuto:
-				if ansi == "never" {
+				switch {
+				case ansi == "never":
 					ui.Mode = ui.ModePlain
+					ep = ui.NewPlainWriter(dockerCli.Err())
+				case dockerCli.Out().IsTerminal():
+					ep = ui.NewTTYWriter(dockerCli.Err())
+				default:
+					ep = ui.NewPlainWriter(dockerCli.Err())
 				}
 			case ui.ModeTTY:
 				if ansi == "never" {
 					return fmt.Errorf("can't use --progress tty while ANSI support is disabled")
 				}
 				ui.Mode = ui.ModeTTY
+				ep = ui.NewTTYWriter(dockerCli.Err())
+
 			case ui.ModePlain:
 				if ansi == "always" {
 					return fmt.Errorf("can't use --progress plain while ANSI support is forced")
 				}
 				ui.Mode = ui.ModePlain
+				ep = ui.NewPlainWriter(dockerCli.Err())
 			case ui.ModeQuiet, "none":
 				ui.Mode = ui.ModeQuiet
+				ep = ui.NewQuietWriter()
 			case ui.ModeJSON:
 				ui.Mode = ui.ModeJSON
 				logrus.SetFormatter(&logrus.JSONFormatter{})
+				ep = ui.NewJSONWriter(dockerCli.Err())
 			default:
 				return fmt.Errorf("unsupported --progress value %q", opts.Progress)
 			}
+			backendOptions.Add(compose.WithEventProcessor(ep))
 
 			// (4) options validation / normalization
 			if opts.WorkDir != "" {
diff --git a/cmd/compose/run.go b/cmd/compose/run.go
index b35a003c..0894a8cb 100644
--- a/cmd/compose/run.go
+++ b/cmd/compose/run.go
@@ -25,6 +25,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/dotenv"
 	"github.com/compose-spec/compose-go/v2/format"
 	"github.com/docker/compose/v2/pkg/compose"
+	"github.com/docker/compose/v2/pkg/progress"
 	xprogress "github.com/moby/buildkit/util/progress/progressui"
 	"github.com/sirupsen/logrus"
 
@@ -38,7 +39,6 @@ import (
 
 	"github.com/docker/cli/cli"
 	"github.com/docker/compose/v2/pkg/api"
-	"github.com/docker/compose/v2/pkg/progress"
 	"github.com/docker/compose/v2/pkg/utils"
 )
 
diff --git a/cmd/compose/up.go b/cmd/compose/up.go
index 08ea1837..7b52792d 100644
--- a/cmd/compose/up.go
+++ b/cmd/compose/up.go
@@ -27,6 +27,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/docker/cli/cli/command"
 	"github.com/docker/compose/v2/pkg/compose"
+	ui "github.com/docker/compose/v2/pkg/progress"
 	xprogress "github.com/moby/buildkit/util/progress/progressui"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
@@ -34,7 +35,6 @@ import (
 
 	"github.com/docker/compose/v2/cmd/formatter"
 	"github.com/docker/compose/v2/pkg/api"
-	ui "github.com/docker/compose/v2/pkg/progress"
 	"github.com/docker/compose/v2/pkg/utils"
 )
 
diff --git a/pkg/compose/build.go b/pkg/compose/build.go
index 986761cd..feddf56f 100644
--- a/pkg/compose/build.go
+++ b/pkg/compose/build.go
@@ -58,13 +58,13 @@ func (s *composeService) Build(ctx context.Context, project *types.Project, opti
 	if err != nil {
 		return err
 	}
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return tracing.SpanWrapFunc("project/build", tracing.ProjectOptions(ctx, project),
 			func(ctx context.Context) error {
 				_, err := s.build(ctx, project, options, nil)
 				return err
 			})(ctx)
-	}, s.stdinfo(), "Building")
+	}, "build", s.events)
 }
 
 //nolint:gocyclo
@@ -199,22 +199,17 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 		return -1
 	}
 
-	cw := progress.ContextWriter(ctx)
 	err = InDependencyOrder(ctx, project, func(ctx context.Context, name string) error {
 		service, ok := serviceToBuild[name]
 		if !ok {
 			return nil
 		}
-		serviceName := fmt.Sprintf("Service %s", name)
-
 		if !buildkitEnabled {
 			trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "classic"))
-			cw.Event(progress.BuildingEvent(serviceName))
 			id, err := s.doBuildClassic(ctx, project, service, options)
 			if err != nil {
 				return err
 			}
-			cw.Event(progress.BuiltEvent(serviceName))
 			builtDigests[getServiceIndex(name)] = id
 
 			if options.Push {
@@ -231,6 +226,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 		if err != nil {
 			return err
 		}
+		s.events.On(progress.BuildingEvent(buildOptions.Tags[0]))
 
 		trace.SpanFromContext(ctx).SetAttributes(attribute.String("builder", "buildkit"))
 		digest, err := s.doBuildBuildkit(ctx, name, buildOptions, w, nodes)
@@ -260,7 +256,7 @@ func (s *composeService) build(ctx context.Context, project *types.Project, opti
 			service := project.Services[names[i]]
 			imageRef := api.GetImageNameOrDefault(service, project.Name)
 			imageIDs[imageRef] = imageDigest
-			cw.Event(progress.BuiltEvent(names[i]))
+			s.events.On(progress.BuiltEvent(imageRef))
 		}
 	}
 	return imageIDs, err
diff --git a/pkg/compose/build_bake.go b/pkg/compose/build_bake.go
index b161d3f2..8407b8a1 100644
--- a/pkg/compose/build_bake.go
+++ b/pkg/compose/build_bake.go
@@ -226,6 +226,8 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
 		}
 
 		image := api.GetImageNameOrDefault(service, project.Name)
+		s.events.On(progress.BuildingEvent(image))
+
 		expectedImages[serviceName] = image
 
 		pull := service.Build.Pull || options.Pull
@@ -340,7 +342,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
 	logrus.Debugf("Executing bake with args: %v", args)
 
 	if s.dryRun {
-		return dryRunBake(ctx, cfg), nil
+		return s.dryRunBake(cfg), nil
 	}
 	cmd := exec.CommandContext(ctx, buildx.Path, args...)
 
@@ -417,7 +419,6 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
 		return nil, err
 	}
 
-	cw := progress.ContextWriter(ctx)
 	results := map[string]string{}
 	for name := range serviceToBeBuild {
 		image := expectedImages[name]
@@ -427,7 +428,7 @@ func (s *composeService) doBuildBake(ctx context.Context, project *types.Project
 			return nil, fmt.Errorf("build result not found in Bake metadata for service %s", name)
 		}
 		results[image] = built.Digest
-		cw.Event(progress.BuiltEvent(image))
+		s.events.On(progress.BuiltEvent(image))
 	}
 	return results, nil
 }
@@ -565,27 +566,26 @@ func dockerFilePath(ctxName string, dockerfile string) string {
 	return dockerfile
 }
 
-func dryRunBake(ctx context.Context, cfg bakeConfig) map[string]string {
-	w := progress.ContextWriter(ctx)
+func (s composeService) dryRunBake(cfg bakeConfig) map[string]string {
 	bakeResponse := map[string]string{}
 	for name, target := range cfg.Targets {
 		dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name)))
-		displayDryRunBuildEvent(w, name, dryRunUUID, target.Tags[0])
+		s.displayDryRunBuildEvent(name, dryRunUUID, target.Tags[0])
 		bakeResponse[name] = dryRunUUID
 	}
 	for name := range bakeResponse {
-		w.Event(progress.BuiltEvent(name))
+		s.events.On(progress.BuiltEvent(name))
 	}
 	return bakeResponse
 }
 
-func displayDryRunBuildEvent(w progress.Writer, name string, dryRunUUID, tag string) {
-	w.Event(progress.Event{
+func (s composeService) displayDryRunBuildEvent(name, dryRunUUID, tag string) {
+	s.events.On(progress.Event{
 		ID:     name + " ==>",
 		Status: progress.Done,
 		Text:   fmt.Sprintf("==> writing image %s", dryRunUUID),
 	})
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:     name + " ==> ==>",
 		Status: progress.Done,
 		Text:   fmt.Sprintf(`naming to %s`, tag),
diff --git a/pkg/compose/build_buildkit.go b/pkg/compose/build_buildkit.go
index ccc5ae5d..23046795 100644
--- a/pkg/compose/build_buildkit.go
+++ b/pkg/compose/build_buildkit.go
@@ -30,7 +30,6 @@ import (
 	"github.com/docker/buildx/util/confutil"
 	"github.com/docker/buildx/util/dockerutil"
 	buildx "github.com/docker/buildx/util/progress"
-	"github.com/docker/compose/v2/pkg/progress"
 	"github.com/moby/buildkit/client"
 )
 
@@ -40,7 +39,7 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, service string, op
 		err      error
 	)
 	if s.dryRun {
-		response = s.dryRunBuildResponse(ctx, service, opts)
+		response = s.dryRunBuildResponse(service, opts)
 	} else {
 		response, err = build.Build(ctx, nodes,
 			map[string]build.Options{service: opts},
@@ -66,11 +65,10 @@ func (s *composeService) doBuildBuildkit(ctx context.Context, service string, op
 	return "", fmt.Errorf("buildkit response is missing expected result for %s", service)
 }
 
-func (s composeService) dryRunBuildResponse(ctx context.Context, name string, options build.Options) map[string]*client.SolveResponse {
-	w := progress.ContextWriter(ctx)
+func (s composeService) dryRunBuildResponse(name string, options build.Options) map[string]*client.SolveResponse {
 	buildResponse := map[string]*client.SolveResponse{}
 	dryRunUUID := fmt.Sprintf("dryRun-%x", sha1.Sum([]byte(name)))
-	displayDryRunBuildEvent(w, name, dryRunUUID, options.Tags[0])
+	s.displayDryRunBuildEvent(name, dryRunUUID, options.Tags[0])
 	buildResponse[name] = &client.SolveResponse{ExporterResponse: map[string]string{
 		"containerimage.digest": dryRunUUID,
 	}}
diff --git a/pkg/compose/build_classic.go b/pkg/compose/build_classic.go
index fb3fd296..6a29d871 100644
--- a/pkg/compose/build_classic.go
+++ b/pkg/compose/build_classic.go
@@ -30,6 +30,7 @@ import (
 	"github.com/docker/cli/cli"
 	"github.com/docker/cli/cli/command/image/build"
 	"github.com/docker/compose/v2/pkg/api"
+	progress2 "github.com/docker/compose/v2/pkg/progress"
 	buildtypes "github.com/docker/docker/api/types/build"
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/registry"
@@ -183,6 +184,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
 
 	ctx, cancel := context.WithCancel(ctx)
 	defer cancel()
+	s.events.On(progress2.BuildingEvent(imageName))
 	response, err := s.apiClient().ImageBuild(ctx, body, buildOpts)
 	if err != nil {
 		return "", err
@@ -211,6 +213,7 @@ func (s *composeService) doBuildClassic(ctx context.Context, project *types.Proj
 		}
 		return "", err
 	}
+	s.events.On(progress2.BuiltEvent(imageName))
 	return imageID, nil
 }
 
diff --git a/pkg/compose/commit.go b/pkg/compose/commit.go
index 0edac433..520f795f 100644
--- a/pkg/compose/commit.go
+++ b/pkg/compose/commit.go
@@ -27,9 +27,9 @@ import (
 )
 
 func (s *composeService) Commit(ctx context.Context, projectName string, options api.CommitOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.commit(ctx, projectName, options)
-	}, s.stdinfo(), "Committing")
+	}, "commit", s.events)
 }
 
 func (s *composeService) commit(ctx context.Context, projectName string, options api.CommitOptions) error {
@@ -40,12 +40,10 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
 		return err
 	}
 
-	w := progress.ContextWriter(ctx)
-
 	name := getCanonicalContainerName(ctr)
 	msg := fmt.Sprintf("Commit %s", name)
 
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:         name,
 		Text:       msg,
 		Status:     progress.Working,
@@ -53,7 +51,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
 	})
 
 	if s.dryRun {
-		w.Event(progress.Event{
+		s.events.On(progress.Event{
 			ID:         name,
 			Text:       msg,
 			Status:     progress.Done,
@@ -74,7 +72,7 @@ func (s *composeService) commit(ctx context.Context, projectName string, options
 		return err
 	}
 
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:         name,
 		Text:       msg,
 		Status:     progress.Done,
diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go
index 0e40a478..d475d9a9 100644
--- a/pkg/compose/compose.go
+++ b/pkg/compose/compose.go
@@ -32,6 +32,7 @@ import (
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/docker/cli/cli/flags"
 	"github.com/docker/cli/cli/streams"
+	"github.com/docker/compose/v2/pkg/progress"
 	"github.com/docker/docker/api/types/container"
 	"github.com/docker/docker/api/types/filters"
 	"github.com/docker/docker/api/types/network"
@@ -94,6 +95,9 @@ func NewComposeService(dockerCli command.Cli, options ...Option) (api.Compose, e
 			return defaultValue, nil
 		}
 	}
+	if s.events == nil {
+		s.events = progress.NewQuietWriter()
+	}
 
 	// If custom streams were provided, wrap the Docker CLI to use them
 	if s.outStream != nil || s.errStream != nil || s.inStream != nil {
@@ -191,10 +195,21 @@ func WithDryRun(s *composeService) error {
 
 type Prompt func(message string, defaultValue bool) (bool, error)
 
+// WithEventProcessor configure component to get notified on Compose operation and progress events.
+// Typically used to configure a progress UI
+func WithEventProcessor(bus progress.EventProcessor) Option {
+	return func(s *composeService) error {
+		s.events = bus
+		return nil
+	}
+}
+
 type composeService struct {
 	dockerCli command.Cli
 	// prompt is used to interact with user and confirm actions
 	prompt Prompt
+	// eventBus collects tasks execution events
+	events progress.EventProcessor
 
 	// Optional overrides for specific components (for SDK users)
 	outStream   io.Writer
diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go
index e75fa94e..992b3987 100644
--- a/pkg/compose/convergence.go
+++ b/pkg/compose/convergence.go
@@ -57,7 +57,7 @@ const (
 // Cross services dependencies are managed by creating services in expected order and updating `service:xx` reference
 // when a service has converged, so dependent ones can be managed with resolved containers references.
 type convergence struct {
-	service    *composeService
+	compose    *composeService
 	services   map[string]Containers
 	networks   map[string]string
 	volumes    map[string]string
@@ -86,7 +86,7 @@ func newConvergence(services []string, state Containers, networks map[string]str
 		observedState[service] = append(observedState[service], c)
 	}
 	return &convergence{
-		service:  s,
+		compose:  s,
 		services: observedState,
 		networks: networks,
 		volumes:  volumes,
@@ -112,7 +112,7 @@ func (c *convergence) apply(ctx context.Context, project *types.Project, options
 
 func (c *convergence) ensureService(ctx context.Context, project *types.Project, service types.ServiceConfig, recreate string, inherit bool, timeout *time.Duration) error { //nolint:gocyclo
 	if service.Provider != nil {
-		return c.service.runPlugin(ctx, project, service, "up")
+		return c.compose.runPlugin(ctx, project, service, "up")
 	}
 	expected, err := getScale(service)
 	if err != nil {
@@ -159,7 +159,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 			ctr := ctr
 			traceOpts := append(tracing.ServiceOptions(service), tracing.ContainerOptions(ctr)...)
 			eg.Go(tracing.SpanWrapFuncForErrGroup(ctx, "service/scale/down", traceOpts, func(ctx context.Context) error {
-				return c.service.stopAndRemoveContainer(ctx, ctr, &service, timeout, false)
+				return c.compose.stopAndRemoveContainer(ctx, ctr, &service, timeout, false)
 			}))
 			continue
 		}
@@ -176,7 +176,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 
 			i, ctr := i, ctr
 			eg.Go(tracing.SpanWrapFuncForErrGroup(ctx, "container/recreate", tracing.ContainerOptions(ctr), func(ctx context.Context) error {
-				recreated, err := c.service.recreateContainer(ctx, project, service, ctr, inherit, timeout)
+				recreated, err := c.compose.recreateContainer(ctx, project, service, ctr, inherit, timeout)
 				updated[i] = recreated
 				return err
 			}))
@@ -184,18 +184,17 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 		}
 
 		// Enforce non-diverged containers are running
-		w := progress.ContextWriter(ctx)
 		name := getContainerProgressName(ctr)
 		switch ctr.State {
 		case container.StateRunning:
-			w.Event(progress.RunningEvent(name))
+			c.compose.events.On(progress.RunningEvent(name))
 		case container.StateCreated:
 		case container.StateRestarting:
 		case container.StateExited:
 		default:
 			ctr := ctr
 			eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "service/start", tracing.ContainerOptions(ctr), func(ctx context.Context) error {
-				return c.service.startContainer(ctx, ctr)
+				return c.compose.startContainer(ctx, ctr)
 			}))
 		}
 		updated[i] = ctr
@@ -214,7 +213,7 @@ func (c *convergence) ensureService(ctx context.Context, project *types.Project,
 				UseNetworkAliases: true,
 				Labels:            mergeLabels(service.Labels, service.CustomLabels),
 			}
-			ctr, err := c.service.createContainer(ctx, project, service, name, number, opts)
+			ctr, err := c.compose.createContainer(ctx, project, service, name, number, opts)
 			updated[actual+i] = ctr
 			return err
 		}))
@@ -234,7 +233,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type
 	if len(dependents) == 0 {
 		return nil
 	}
-	err := c.service.stop(ctx, project.Name, api.StopOptions{
+	err := c.compose.stop(ctx, project.Name, api.StopOptions{
 		Services: dependents,
 		Project:  project,
 	}, nil)
@@ -454,7 +453,6 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
 		ctx = withTimeout
 	}
 	eg, _ := errgroup.WithContext(ctx)
-	w := progress.ContextWriter(ctx)
 	for dep, config := range dependencies {
 		if shouldWait, err := shouldWaitForDependency(dep, config, project); err != nil {
 			return err
@@ -463,7 +461,7 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
 		}
 
 		waitingFor := containers.filter(isService(dep), isNotOneOff)
-		w.Events(containerEvents(waitingFor, progress.Waiting))
+		s.events.On(containerEvents(waitingFor, progress.Waiting)...)
 		if len(waitingFor) == 0 {
 			if config.Required {
 				return fmt.Errorf("%s is missing dependency %s", dependant, dep)
@@ -486,29 +484,31 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
 					healthy, err := s.isServiceHealthy(ctx, waitingFor, true)
 					if err != nil {
 						if !config.Required {
-							w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %q is not running or is unhealthy", dep)))
+							s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
+								fmt.Sprintf("optional dependency %q is not running or is unhealthy", dep))...)
 							logrus.Warnf("optional dependency %q is not running or is unhealthy: %s", dep, err.Error())
 							return nil
 						}
 						return err
 					}
 					if healthy {
-						w.Events(containerEvents(waitingFor, progress.Healthy))
+						s.events.On(containerEvents(waitingFor, progress.Healthy)...)
 						return nil
 					}
 				case types.ServiceConditionHealthy:
 					healthy, err := s.isServiceHealthy(ctx, waitingFor, false)
 					if err != nil {
 						if !config.Required {
-							w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %q failed to start", dep)))
+							s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
+								fmt.Sprintf("optional dependency %q failed to start", dep))...)
 							logrus.Warnf("optional dependency %q failed to start: %s", dep, err.Error())
 							return nil
 						}
-						w.Events(containerEvents(waitingFor, progress.ErrorEvent))
+						s.events.On(containerEvents(waitingFor, progress.ErrorEvent)...)
 						return fmt.Errorf("dependency failed to start: %w", err)
 					}
 					if healthy {
-						w.Events(containerEvents(waitingFor, progress.Healthy))
+						s.events.On(containerEvents(waitingFor, progress.Healthy)...)
 						return nil
 					}
 				case types.ServiceConditionCompletedSuccessfully:
@@ -518,20 +518,21 @@ func (s *composeService) waitDependencies(ctx context.Context, project *types.Pr
 					}
 					if exited {
 						if code == 0 {
-							w.Events(containerEvents(waitingFor, progress.Exited))
+							s.events.On(containerEvents(waitingFor, progress.Exited)...)
 							return nil
 						}
 
 						messageSuffix := fmt.Sprintf("%q didn't complete successfully: exit %d", dep, code)
 						if !config.Required {
 							// optional -> mark as skipped & don't propagate error
-							w.Events(containerReasonEvents(waitingFor, progress.SkippedEvent, fmt.Sprintf("optional dependency %s", messageSuffix)))
+							s.events.On(containerReasonEvents(waitingFor, progress.SkippedEvent,
+								fmt.Sprintf("optional dependency %s", messageSuffix))...)
 							logrus.Warnf("optional dependency %s", messageSuffix)
 							return nil
 						}
 
 						msg := fmt.Sprintf("service %s", messageSuffix)
-						w.Events(containerReasonEvents(waitingFor, progress.ErrorMessageEvent, msg))
+						s.events.On(containerReasonEvents(waitingFor, progress.ErrorMessageEvent, msg)...)
 						return errors.New(msg)
 					}
 				default:
@@ -593,13 +594,12 @@ func nextContainerNumber(containers []container.Summary) int {
 func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig,
 	name string, number int, opts createOptions,
 ) (ctr container.Summary, err error) {
-	w := progress.ContextWriter(ctx)
 	eventName := "Container " + name
-	w.Event(progress.CreatingEvent(eventName))
-	ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts, w)
+	s.events.On(progress.CreatingEvent(eventName))
+	ctr, err = s.createMobyContainer(ctx, project, service, name, number, nil, opts)
 	if err != nil {
 		if ctx.Err() == nil {
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:         eventName,
 				Status:     progress.Error,
 				StatusText: err.Error(),
@@ -607,19 +607,18 @@ func (s *composeService) createContainer(ctx context.Context, project *types.Pro
 		}
 		return
 	}
-	w.Event(progress.CreatedEvent(eventName))
+	s.events.On(progress.CreatedEvent(eventName))
 	return
 }
 
 func (s *composeService) recreateContainer(ctx context.Context, project *types.Project, service types.ServiceConfig,
 	replaced container.Summary, inherit bool, timeout *time.Duration,
 ) (created container.Summary, err error) {
-	w := progress.ContextWriter(ctx)
 	eventName := getContainerProgressName(replaced)
-	w.Event(progress.NewEvent(eventName, progress.Working, "Recreate"))
+	s.events.On(progress.NewEvent(eventName, progress.Working, "Recreate"))
 	defer func() {
 		if err != nil && ctx.Err() == nil {
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:         eventName,
 				Status:     progress.Error,
 				StatusText: err.Error(),
@@ -649,7 +648,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
 		UseNetworkAliases: true,
 		Labels:            mergeLabels(service.Labels, service.CustomLabels).Add(api.ContainerReplaceLabel, replacedContainerName),
 	}
-	created, err = s.createMobyContainer(ctx, project, service, tmpName, number, inherited, opts, w)
+	created, err = s.createMobyContainer(ctx, project, service, tmpName, number, inherited, opts)
 	if err != nil {
 		return created, err
 	}
@@ -670,7 +669,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
 		return created, err
 	}
 
-	w.Event(progress.NewEvent(eventName, progress.Done, "Recreated"))
+	s.events.On(progress.NewEvent(eventName, progress.Done, "Recreated"))
 	return created, err
 }
 
@@ -678,26 +677,19 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P
 var startMx sync.Mutex
 
 func (s *composeService) startContainer(ctx context.Context, ctr container.Summary) error {
-	w := progress.ContextWriter(ctx)
-	w.Event(progress.NewEvent(getContainerProgressName(ctr), progress.Working, "Restart"))
+	s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Working, "Restart"))
 	startMx.Lock()
 	defer startMx.Unlock()
 	err := s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{})
 	if err != nil {
 		return err
 	}
-	w.Event(progress.NewEvent(getContainerProgressName(ctr), progress.Done, "Restarted"))
+	s.events.On(progress.NewEvent(getContainerProgressName(ctr), progress.Done, "Restarted"))
 	return nil
 }
 
-func (s *composeService) createMobyContainer(ctx context.Context,
-	project *types.Project,
-	service types.ServiceConfig,
-	name string,
-	number int,
-	inherit *container.Summary,
-	opts createOptions,
-	w progress.Writer,
+func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig,
+	name string, number int, inherit *container.Summary, opts createOptions,
 ) (container.Summary, error) {
 	var created container.Summary
 	cfgs, err := s.getCreateConfigs(ctx, project, service, number, inherit, opts)
@@ -723,7 +715,7 @@ func (s *composeService) createMobyContainer(ctx context.Context,
 		return created, err
 	}
 	for _, warning := range response.Warnings {
-		w.Event(progress.Event{
+		s.events.On(progress.Event{
 			ID:     service.Name,
 			Status: progress.Warning,
 			Text:   warning,
@@ -894,7 +886,6 @@ func (s *composeService) startService(ctx context.Context,
 		return fmt.Errorf("service %q has no container to start", service.Name)
 	}
 
-	w := progress.ContextWriter(ctx)
 	for _, ctr := range containers.filter(isService(service.Name)) {
 		if ctr.State == container.StateRunning {
 			continue
@@ -911,7 +902,7 @@ func (s *composeService) startService(ctx context.Context,
 		}
 
 		eventName := getContainerProgressName(ctr)
-		w.Event(progress.StartingEvent(eventName))
+		s.events.On(progress.StartingEvent(eventName))
 		err = s.apiClient().ContainerStart(ctx, ctr.ID, container.StartOptions{})
 		if err != nil {
 			return err
@@ -924,7 +915,7 @@ func (s *composeService) startService(ctx context.Context,
 			}
 		}
 
-		w.Event(progress.StartedEvent(eventName))
+		s.events.On(progress.StartedEvent(eventName))
 	}
 	return nil
 }
diff --git a/pkg/compose/cp.go b/pkg/compose/cp.go
index b7db3296..65c9185b 100644
--- a/pkg/compose/cp.go
+++ b/pkg/compose/cp.go
@@ -43,9 +43,9 @@ const (
 )
 
 func (s *composeService) Copy(ctx context.Context, projectName string, options api.CopyOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.copy(ctx, projectName, options)
-	}, s.stdinfo(), "Copying")
+	}, "copy", s.events)
 }
 
 func (s *composeService) copy(ctx context.Context, projectName string, options api.CopyOptions) error {
@@ -79,7 +79,6 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a
 		return err
 	}
 
-	w := progress.ContextWriter(ctx)
 	g := errgroup.Group{}
 	for _, cont := range containers {
 		ctr := cont
@@ -91,7 +90,7 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a
 			} else {
 				msg = fmt.Sprintf("copy %s to %s:%s", srcPath, name, dstPath)
 			}
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:         name,
 				Text:       msg,
 				Status:     progress.Working,
@@ -100,7 +99,7 @@ func (s *composeService) copy(ctx context.Context, projectName string, options a
 			if err := copyFunc(ctx, ctr.ID, srcPath, dstPath, options); err != nil {
 				return err
 			}
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:         name,
 				Text:       msg,
 				Status:     progress.Done,
diff --git a/pkg/compose/create.go b/pkg/compose/create.go
index 27735090..07f1085a 100644
--- a/pkg/compose/create.go
+++ b/pkg/compose/create.go
@@ -61,9 +61,9 @@ type createConfigs struct {
 }
 
 func (s *composeService) Create(ctx context.Context, project *types.Project, createOpts api.CreateOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.create(ctx, project, createOpts)
-	}, s.stdinfo(), "Creating")
+	}, "create", s.events)
 }
 
 func (s *composeService) create(ctx context.Context, project *types.Project, options api.CreateOptions) error {
@@ -1394,15 +1394,14 @@ func (s *composeService) resolveOrCreateNetwork(ctx context.Context, project *ty
 	}
 
 	networkEventName := fmt.Sprintf("Network %s", n.Name)
-	w := progress.ContextWriter(ctx)
-	w.Event(progress.CreatingEvent(networkEventName))
+	s.events.On(progress.CreatingEvent(networkEventName))
 
 	resp, err := s.apiClient().NetworkCreate(ctx, n.Name, createOpts)
 	if err != nil {
-		w.Event(progress.ErrorEvent(networkEventName))
+		s.events.On(progress.ErrorEvent(networkEventName))
 		return "", fmt.Errorf("failed to create network %s: %w", n.Name, err)
 	}
-	w.Event(progress.CreatedEvent(networkEventName))
+	s.events.On(progress.CreatedEvent(networkEventName))
 
 	err = s.connectNetwork(ctx, n.Name, dangledContainers, nil)
 	if err != nil {
@@ -1444,7 +1443,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ
 
 	err = s.apiClient().NetworkRemove(ctx, n.Name)
 	eventName := fmt.Sprintf("Network %s", n.Name)
-	progress.ContextWriter(ctx).Event(progress.RemovedEvent(eventName))
+	s.events.On(progress.RemovedEvent(eventName))
 	return containers, err
 }
 
@@ -1623,8 +1622,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string,
 
 func (s *composeService) createVolume(ctx context.Context, volume types.VolumeConfig) error {
 	eventName := fmt.Sprintf("Volume %s", volume.Name)
-	w := progress.ContextWriter(ctx)
-	w.Event(progress.CreatingEvent(eventName))
+	s.events.On(progress.CreatingEvent(eventName))
 	hash, err := VolumeHash(volume)
 	if err != nil {
 		return err
@@ -1637,9 +1635,9 @@ func (s *composeService) createVolume(ctx context.Context, volume types.VolumeCo
 		DriverOpts: volume.DriverOpts,
 	})
 	if err != nil {
-		w.Event(progress.ErrorEvent(eventName))
+		s.events.On(progress.ErrorEvent(eventName))
 		return err
 	}
-	w.Event(progress.CreatedEvent(eventName))
+	s.events.On(progress.CreatedEvent(eventName))
 	return nil
 }
diff --git a/pkg/compose/down.go b/pkg/compose/down.go
index e9619fbe..159c42a2 100644
--- a/pkg/compose/down.go
+++ b/pkg/compose/down.go
@@ -40,11 +40,10 @@ type downOp func() error
 func (s *composeService) Down(ctx context.Context, projectName string, options api.DownOptions) error {
 	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.down(ctx, strings.ToLower(projectName), options)
-	}, s.stdinfo())
+	}, "down", s.events)
 }
 
 func (s *composeService) down(ctx context.Context, projectName string, options api.DownOptions) error { //nolint:gocyclo
-	w := progress.ContextWriter(ctx)
 	resourceToRemove := false
 
 	include := oneOffExclude
@@ -102,10 +101,10 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
 		}
 	}
 
-	ops := s.ensureNetworksDown(ctx, project, w)
+	ops := s.ensureNetworksDown(ctx, project)
 
 	if options.Images != "" {
-		imgOps, err := s.ensureImagesDown(ctx, project, options, w)
+		imgOps, err := s.ensureImagesDown(ctx, project, options)
 		if err != nil {
 			return err
 		}
@@ -113,7 +112,7 @@ func (s *composeService) down(ctx context.Context, projectName string, options a
 	}
 
 	if options.Volumes {
-		ops = append(ops, s.ensureVolumesDown(ctx, project, w)...)
+		ops = append(ops, s.ensureVolumesDown(ctx, project)...)
 	}
 
 	if !resourceToRemove && len(ops) == 0 {
@@ -144,7 +143,7 @@ func checkSelectedServices(options api.DownOptions, project *types.Project) ([]s
 	return services, nil
 }
 
-func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.Project, w progress.Writer) []downOp {
+func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.Project) []downOp {
 	var ops []downOp
 	for _, vol := range project.Volumes {
 		if vol.External {
@@ -152,14 +151,14 @@ func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.P
 		}
 		volumeName := vol.Name
 		ops = append(ops, func() error {
-			return s.removeVolume(ctx, volumeName, w)
+			return s.removeVolume(ctx, volumeName)
 		})
 	}
 
 	return ops
 }
 
-func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Project, options api.DownOptions, w progress.Writer) ([]downOp, error) {
+func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Project, options api.DownOptions) ([]downOp, error) {
 	imagePruner := NewImagePruner(s.apiClient(), project)
 	pruneOpts := ImagePruneOptions{
 		Mode:          ImagePruneMode(options.Images),
@@ -174,13 +173,13 @@ func (s *composeService) ensureImagesDown(ctx context.Context, project *types.Pr
 	for i := range images {
 		img := images[i]
 		ops = append(ops, func() error {
-			return s.removeImage(ctx, img, w)
+			return s.removeImage(ctx, img)
 		})
 	}
 	return ops, nil
 }
 
-func (s *composeService) ensureNetworksDown(ctx context.Context, project *types.Project, w progress.Writer) []downOp {
+func (s *composeService) ensureNetworksDown(ctx context.Context, project *types.Project) []downOp {
 	var ops []downOp
 	for key, n := range project.Networks {
 		if n.External {
@@ -190,13 +189,13 @@ func (s *composeService) ensureNetworksDown(ctx context.Context, project *types.
 		networkKey := key
 		idOrName := n.Name
 		ops = append(ops, func() error {
-			return s.removeNetwork(ctx, networkKey, project.Name, idOrName, w)
+			return s.removeNetwork(ctx, networkKey, project.Name, idOrName)
 		})
 	}
 	return ops
 }
 
-func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName string, projectName string, name string, w progress.Writer) error {
+func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName string, projectName string, name string) error {
 	networks, err := s.apiClient().NetworkList(ctx, network.ListOptions{
 		Filters: filters.NewArgs(
 			projectFilter(projectName),
@@ -211,7 +210,7 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
 	}
 
 	eventName := fmt.Sprintf("Network %s", name)
-	w.Event(progress.RemovingEvent(eventName))
+	s.events.On(progress.RemovingEvent(eventName))
 
 	var found int
 	for _, net := range networks {
@@ -220,14 +219,14 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
 		}
 		nw, err := s.apiClient().NetworkInspect(ctx, net.ID, network.InspectOptions{})
 		if errdefs.IsNotFound(err) {
-			w.Event(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
+			s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
 			return nil
 		}
 		if err != nil {
 			return err
 		}
 		if len(nw.Containers) > 0 {
-			w.Event(progress.NewEvent(eventName, progress.Warning, "Resource is still in use"))
+			s.events.On(progress.NewEvent(eventName, progress.Warning, "Resource is still in use"))
 			found++
 			continue
 		}
@@ -236,10 +235,10 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
 			if errdefs.IsNotFound(err) {
 				continue
 			}
-			w.Event(progress.ErrorEvent(eventName))
+			s.events.On(progress.ErrorEvent(eventName))
 			return fmt.Errorf("failed to remove network %s: %w", name, err)
 		}
-		w.Event(progress.RemovedEvent(eventName))
+		s.events.On(progress.RemovedEvent(eventName))
 		found++
 	}
 
@@ -247,32 +246,32 @@ func (s *composeService) removeNetwork(ctx context.Context, composeNetworkName s
 		// in practice, it's extremely unlikely for this to ever occur, as it'd
 		// mean the network was present when we queried at the start of this
 		// method but was then deleted by something else in the interim
-		w.Event(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
+		s.events.On(progress.NewEvent(eventName, progress.Warning, "No resource found to remove"))
 		return nil
 	}
 	return nil
 }
 
-func (s *composeService) removeImage(ctx context.Context, image string, w progress.Writer) error {
+func (s *composeService) removeImage(ctx context.Context, image string) error {
 	id := fmt.Sprintf("Image %s", image)
-	w.Event(progress.NewEvent(id, progress.Working, "Removing"))
+	s.events.On(progress.NewEvent(id, progress.Working, "Removing"))
 	_, err := s.apiClient().ImageRemove(ctx, image, imageapi.RemoveOptions{})
 	if err == nil {
-		w.Event(progress.NewEvent(id, progress.Done, "Removed"))
+		s.events.On(progress.NewEvent(id, progress.Done, "Removed"))
 		return nil
 	}
 	if errdefs.IsConflict(err) {
-		w.Event(progress.NewEvent(id, progress.Warning, "Resource is still in use"))
+		s.events.On(progress.NewEvent(id, progress.Warning, "Resource is still in use"))
 		return nil
 	}
 	if errdefs.IsNotFound(err) {
-		w.Event(progress.NewEvent(id, progress.Done, "Warning: No resource found to remove"))
+		s.events.On(progress.NewEvent(id, progress.Done, "Warning: No resource found to remove"))
 		return nil
 	}
 	return err
 }
 
-func (s *composeService) removeVolume(ctx context.Context, id string, w progress.Writer) error {
+func (s *composeService) removeVolume(ctx context.Context, id string) error {
 	resource := fmt.Sprintf("Volume %s", id)
 
 	_, err := s.apiClient().VolumeInspect(ctx, id)
@@ -281,30 +280,26 @@ func (s *composeService) removeVolume(ctx context.Context, id string, w progress
 		return nil
 	}
 
-	w.Event(progress.NewEvent(resource, progress.Working, "Removing"))
+	s.events.On(progress.NewEvent(resource, progress.Working, "Removing"))
 	err = s.apiClient().VolumeRemove(ctx, id, true)
 	if err == nil {
-		w.Event(progress.NewEvent(resource, progress.Done, "Removed"))
+		s.events.On(progress.NewEvent(resource, progress.Done, "Removed"))
 		return nil
 	}
 	if errdefs.IsConflict(err) {
-		w.Event(progress.NewEvent(resource, progress.Warning, "Resource is still in use"))
+		s.events.On(progress.NewEvent(resource, progress.Warning, "Resource is still in use"))
 		return nil
 	}
 	if errdefs.IsNotFound(err) {
-		w.Event(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove"))
+		s.events.On(progress.NewEvent(resource, progress.Done, "Warning: No resource found to remove"))
 		return nil
 	}
 	return err
 }
 
-func (s *composeService) stopContainer(
-	ctx context.Context, w progress.Writer,
-	service *types.ServiceConfig, ctr containerType.Summary,
-	timeout *time.Duration, listener api.ContainerEventListener,
-) error {
+func (s *composeService) stopContainer(ctx context.Context, service *types.ServiceConfig, ctr containerType.Summary, timeout *time.Duration, listener api.ContainerEventListener) error {
 	eventName := getContainerProgressName(ctr)
-	w.Event(progress.StoppingEvent(eventName))
+	s.events.On(progress.StoppingEvent(eventName))
 
 	if service != nil {
 		for _, hook := range service.PreStop {
@@ -322,22 +317,18 @@ func (s *composeService) stopContainer(
 	timeoutInSecond := utils.DurationSecondToInt(timeout)
 	err := s.apiClient().ContainerStop(ctx, ctr.ID, containerType.StopOptions{Timeout: timeoutInSecond})
 	if err != nil {
-		w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
+		s.events.On(progress.ErrorMessageEvent(eventName, "Error while Stopping"))
 		return err
 	}
-	w.Event(progress.StoppedEvent(eventName))
+	s.events.On(progress.StoppedEvent(eventName))
 	return nil
 }
 
-func (s *composeService) stopContainers(
-	ctx context.Context, w progress.Writer,
-	serv *types.ServiceConfig, containers []containerType.Summary,
-	timeout *time.Duration, listener api.ContainerEventListener,
-) error {
+func (s *composeService) stopContainers(ctx context.Context, serv *types.ServiceConfig, containers []containerType.Summary, timeout *time.Duration, listener api.ContainerEventListener) error {
 	eg, ctx := errgroup.WithContext(ctx)
 	for _, ctr := range containers {
 		eg.Go(func() error {
-			return s.stopContainer(ctx, w, serv, ctr, timeout, listener)
+			return s.stopContainer(ctx, serv, ctr, timeout, listener)
 		})
 	}
 	return eg.Wait()
@@ -354,26 +345,25 @@ func (s *composeService) removeContainers(ctx context.Context, containers []cont
 }
 
 func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error {
-	w := progress.ContextWriter(ctx)
 	eventName := getContainerProgressName(ctr)
-	err := s.stopContainer(ctx, w, service, ctr, timeout, nil)
+	err := s.stopContainer(ctx, service, ctr, timeout, nil)
 	if errdefs.IsNotFound(err) {
-		w.Event(progress.RemovedEvent(eventName))
+		s.events.On(progress.RemovedEvent(eventName))
 		return nil
 	}
 	if err != nil {
 		return err
 	}
-	w.Event(progress.RemovingEvent(eventName))
+	s.events.On(progress.RemovingEvent(eventName))
 	err = s.apiClient().ContainerRemove(ctx, ctr.ID, containerType.RemoveOptions{
 		Force:         true,
 		RemoveVolumes: volumes,
 	})
 	if err != nil && !errdefs.IsNotFound(err) && !errdefs.IsConflict(err) {
-		w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing"))
+		s.events.On(progress.ErrorMessageEvent(eventName, "Error while Removing"))
 		return err
 	}
-	w.Event(progress.RemovedEvent(eventName))
+	s.events.On(progress.RemovedEvent(eventName))
 	return nil
 }
 
diff --git a/pkg/compose/export.go b/pkg/compose/export.go
index 21dc8ad2..85755352 100644
--- a/pkg/compose/export.go
+++ b/pkg/compose/export.go
@@ -29,9 +29,9 @@ import (
 )
 
 func (s *composeService) Export(ctx context.Context, projectName string, options api.ExportOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.export(ctx, projectName, options)
-	}, s.stdinfo(), "Exporting")
+	}, "export", s.events)
 }
 
 func (s *composeService) export(ctx context.Context, projectName string, options api.ExportOptions) error {
@@ -50,12 +50,10 @@ func (s *composeService) export(ctx context.Context, projectName string, options
 		return fmt.Errorf("failed to export container: %w", err)
 	}
 
-	w := progress.ContextWriter(ctx)
-
 	name := getCanonicalContainerName(container)
 	msg := fmt.Sprintf("export %s to %s", name, options.Output)
 
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:         name,
 		Text:       msg,
 		Status:     progress.Working,
@@ -69,7 +67,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options
 
 	defer func() {
 		if err := responseBody.Close(); err != nil {
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:         name,
 				Text:       msg,
 				Status:     progress.Error,
@@ -94,7 +92,7 @@ func (s *composeService) export(ctx context.Context, projectName string, options
 		}
 	}
 
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:         name,
 		Text:       msg,
 		Status:     progress.Done,
diff --git a/pkg/compose/kill.go b/pkg/compose/kill.go
index badf90e0..1b54cb61 100644
--- a/pkg/compose/kill.go
+++ b/pkg/compose/kill.go
@@ -29,14 +29,12 @@ import (
 )
 
 func (s *composeService) Kill(ctx context.Context, projectName string, options api.KillOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.kill(ctx, strings.ToLower(projectName), options)
-	}, s.stdinfo(), "Killing")
+	}, "kill", s.events)
 }
 
 func (s *composeService) kill(ctx context.Context, projectName string, options api.KillOptions) error {
-	w := progress.ContextWriter(ctx)
-
 	services := options.Services
 
 	var containers Containers
@@ -65,13 +63,13 @@ func (s *composeService) kill(ctx context.Context, projectName string, options a
 	containers.forEach(func(ctr container.Summary) {
 		eg.Go(func() error {
 			eventName := getContainerProgressName(ctr)
-			w.Event(progress.KillingEvent(eventName))
+			s.events.On(progress.KillingEvent(eventName))
 			err := s.apiClient().ContainerKill(ctx, ctr.ID, options.Signal)
 			if err != nil {
-				w.Event(progress.ErrorMessageEvent(eventName, "Error while Killing"))
+				s.events.On(progress.ErrorMessageEvent(eventName, "Error while Killing"))
 				return err
 			}
-			w.Event(progress.KilledEvent(eventName))
+			s.events.On(progress.KilledEvent(eventName))
 			return nil
 		})
 	})
diff --git a/pkg/compose/model.go b/pkg/compose/model.go
index 386e73e5..7ae67321 100644
--- a/pkg/compose/model.go
+++ b/pkg/compose/model.go
@@ -51,19 +51,18 @@ func (s *composeService) ensureModels(ctx context.Context, project *types.Projec
 		return api.SetModelVariables(ctx, project)
 	})
 
-	w := progress.ContextWriter(ctx)
 	for name, config := range project.Models {
 		if config.Name == "" {
 			config.Name = name
 		}
 		eg.Go(func() error {
 			if !slices.Contains(availableModels, config.Model) {
-				err = api.PullModel(ctx, config, quietPull, w)
+				err = api.PullModel(ctx, config, quietPull, s.events)
 				if err != nil {
 					return err
 				}
 			}
-			return api.ConfigureModel(ctx, config, w)
+			return api.ConfigureModel(ctx, config, s.events)
 		})
 	}
 	return eg.Wait()
@@ -102,8 +101,8 @@ func (m *modelAPI) Close() {
 	m.cleanup()
 }
 
-func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, w progress.Writer) error {
-	w.Event(progress.Event{
+func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quietPull bool, events progress.EventProcessor) error {
+	events.On(progress.Event{
 		ID:     model.Name,
 		Status: progress.Working,
 		Text:   "Pulling",
@@ -132,7 +131,7 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet
 		}
 
 		if !quietPull {
-			w.Event(progress.Event{
+			events.On(progress.Event{
 				ID:         model.Name,
 				Status:     progress.Working,
 				Text:       "Pulling",
@@ -143,21 +142,21 @@ func (m *modelAPI) PullModel(ctx context.Context, model types.ModelConfig, quiet
 
 	err = cmd.Wait()
 	if err != nil {
-		w.Event(progress.ErrorMessageEvent(model.Name, err.Error()))
+		events.On(progress.ErrorMessageEvent(model.Name, err.Error()))
 	}
-	w.Event(progress.Event{
-		ID:     model.Name,
-		Status: progress.Working,
-		Text:   "Pulled",
+	events.On(progress.Event{
+		ID:         model.Name,
+		Status:     progress.Working,
+		StatusText: "Pulled",
 	})
 	return err
 }
 
-func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, w progress.Writer) error {
-	w.Event(progress.Event{
-		ID:     config.Name,
-		Status: progress.Working,
-		Text:   "Configuring",
+func (m *modelAPI) ConfigureModel(ctx context.Context, config types.ModelConfig, events progress.EventProcessor) error {
+	events.On(progress.Event{
+		ID:         config.Name,
+		Status:     progress.Working,
+		StatusText: "Configuring",
 	})
 	// configure [--context-size=<n>] MODEL [-- <runtime-flags...>]
 	args := []string{"configure"}
diff --git a/pkg/compose/pause.go b/pkg/compose/pause.go
index 4f86d0e0..cfa5d2c7 100644
--- a/pkg/compose/pause.go
+++ b/pkg/compose/pause.go
@@ -28,9 +28,9 @@ import (
 )
 
 func (s *composeService) Pause(ctx context.Context, projectName string, options api.PauseOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.pause(ctx, strings.ToLower(projectName), options)
-	}, s.stdinfo(), "Pausing")
+	}, "pause", s.events)
 }
 
 func (s *composeService) pause(ctx context.Context, projectName string, options api.PauseOptions) error {
@@ -43,14 +43,13 @@ func (s *composeService) pause(ctx context.Context, projectName string, options
 		containers = containers.filter(isService(options.Project.ServiceNames()...))
 	}
 
-	w := progress.ContextWriter(ctx)
 	eg, ctx := errgroup.WithContext(ctx)
 	containers.forEach(func(container container.Summary) {
 		eg.Go(func() error {
 			err := s.apiClient().ContainerPause(ctx, container.ID)
 			if err == nil {
 				eventName := getContainerProgressName(container)
-				w.Event(progress.NewEvent(eventName, progress.Done, "Paused"))
+				s.events.On(progress.NewEvent(eventName, progress.Done, "Paused"))
 			}
 			return err
 		})
@@ -61,7 +60,7 @@ func (s *composeService) pause(ctx context.Context, projectName string, options
 func (s *composeService) UnPause(ctx context.Context, projectName string, options api.PauseOptions) error {
 	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.unPause(ctx, strings.ToLower(projectName), options)
-	}, s.stdinfo())
+	}, "unpause", s.events)
 }
 
 func (s *composeService) unPause(ctx context.Context, projectName string, options api.PauseOptions) error {
@@ -74,14 +73,13 @@ func (s *composeService) unPause(ctx context.Context, projectName string, option
 		containers = containers.filter(isService(options.Project.ServiceNames()...))
 	}
 
-	w := progress.ContextWriter(ctx)
 	eg, ctx := errgroup.WithContext(ctx)
 	containers.forEach(func(ctr container.Summary) {
 		eg.Go(func() error {
 			err = s.apiClient().ContainerUnpause(ctx, ctr.ID)
 			if err == nil {
 				eventName := getContainerProgressName(ctr)
-				w.Event(progress.NewEvent(eventName, progress.Done, "Unpaused"))
+				s.events.On(progress.NewEvent(eventName, progress.Done, "Unpaused"))
 			}
 			return err
 		})
diff --git a/pkg/compose/plugins.go b/pkg/compose/plugins.go
index 25bebfa9..f0f1d9b0 100644
--- a/pkg/compose/plugins.go
+++ b/pkg/compose/plugins.go
@@ -66,7 +66,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
 		return err
 	}
 
-	variables, err := s.executePlugin(ctx, cmd, command, service)
+	variables, err := s.executePlugin(cmd, command, service)
 	if err != nil {
 		return err
 	}
@@ -85,15 +85,14 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
 	return nil
 }
 
-func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) {
-	pw := progress.ContextWriter(ctx)
+func (s *composeService) executePlugin(cmd *exec.Cmd, command string, service types.ServiceConfig) (types.Mapping, error) {
 	var action string
 	switch command {
 	case "up":
-		pw.Event(progress.CreatingEvent(service.Name))
+		s.events.On(progress.CreatingEvent(service.Name))
 		action = "create"
 	case "down":
-		pw.Event(progress.RemovingEvent(service.Name))
+		s.events.On(progress.RemovingEvent(service.Name))
 		action = "remove"
 	default:
 		return nil, fmt.Errorf("unsupported plugin command: %s", command)
@@ -125,10 +124,10 @@ func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, comma
 		}
 		switch msg.Type {
 		case ErrorType:
-			pw.Event(progress.NewEvent(service.Name, progress.Error, msg.Message))
+			s.events.On(progress.NewEvent(service.Name, progress.Error, msg.Message))
 			return nil, errors.New(msg.Message)
 		case InfoType:
-			pw.Event(progress.NewEvent(service.Name, progress.Working, msg.Message))
+			s.events.On(progress.NewEvent(service.Name, progress.Working, msg.Message))
 		case SetEnvType:
 			key, val, found := strings.Cut(msg.Message, "=")
 			if !found {
@@ -144,14 +143,14 @@ func (s *composeService) executePlugin(ctx context.Context, cmd *exec.Cmd, comma
 
 	err = cmd.Wait()
 	if err != nil {
-		pw.Event(progress.ErrorMessageEvent(service.Name, err.Error()))
+		s.events.On(progress.ErrorMessageEvent(service.Name, err.Error()))
 		return nil, fmt.Errorf("failed to %s service provider: %s", action, err.Error())
 	}
 	switch command {
 	case "up":
-		pw.Event(progress.CreatedEvent(service.Name))
+		s.events.On(progress.CreatedEvent(service.Name))
 	case "down":
-		pw.Event(progress.RemovedEvent(service.Name))
+		s.events.On(progress.RemovedEvent(service.Name))
 	}
 	return variables, nil
 }
diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go
index ad57d830..15abce6f 100644
--- a/pkg/compose/publish.go
+++ b/pkg/compose/publish.go
@@ -43,9 +43,9 @@ import (
 )
 
 func (s *composeService) Publish(ctx context.Context, project *types.Project, repository string, options api.PublishOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.publish(ctx, project, repository, options)
-	}, s.stdinfo(), "Publishing")
+	}, "publish", s.events)
 }
 
 //nolint:gocyclo
@@ -71,8 +71,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
 		return err
 	}
 
-	w := progress.ContextWriter(ctx)
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:     repository,
 		Text:   "publishing",
 		Status: progress.Working,
@@ -94,7 +93,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
 
 		descriptor, err := oci.PushManifest(ctx, resolver, named, layers, options.OCIVersion)
 		if err != nil {
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:     repository,
 				Text:   "publishing",
 				Status: progress.Error,
@@ -146,7 +145,7 @@ func (s *composeService) publish(ctx context.Context, project *types.Project, re
 			}
 		}
 	}
-	w.Event(progress.Event{
+	s.events.On(progress.Event{
 		ID:     repository,
 		Text:   "published",
 		Status: progress.Done,
diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go
index a5cb2a2f..dddfb3fa 100644
--- a/pkg/compose/pull.go
+++ b/pkg/compose/pull.go
@@ -35,6 +35,7 @@ import (
 	"github.com/docker/docker/client"
 	"github.com/docker/docker/pkg/jsonmessage"
 	"github.com/opencontainers/go-digest"
+	"github.com/sirupsen/logrus"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/docker/compose/v2/internal/registry"
@@ -43,9 +44,9 @@ import (
 )
 
 func (s *composeService) Pull(ctx context.Context, project *types.Project, options api.PullOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.pull(ctx, project, options)
-	}, s.stdinfo(), "Pulling")
+	}, "pull", s.events)
 }
 
 func (s *composeService) pull(ctx context.Context, project *types.Project, opts api.PullOptions) error { //nolint:gocyclo
@@ -54,7 +55,6 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 		return err
 	}
 
-	w := progress.ContextWriter(ctx)
 	eg, ctx := errgroup.WithContext(ctx)
 	eg.SetLimit(s.maxConcurrency)
 
@@ -67,7 +67,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 	i := 0
 	for name, service := range project.Services {
 		if service.Image == "" {
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:     name,
 				Status: progress.Done,
 				Text:   "Skipped - No image to be pulled",
@@ -77,16 +77,16 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 
 		switch service.PullPolicy {
 		case types.PullPolicyNever, types.PullPolicyBuild:
-			w.Event(progress.Event{
-				ID:     name,
+			s.events.On(progress.Event{
+				ID:     "Image " + service.Image,
 				Status: progress.Done,
 				Text:   "Skipped",
 			})
 			continue
 		case types.PullPolicyMissing, types.PullPolicyIfNotPresent:
 			if imageAlreadyPresent(service.Image, images) {
-				w.Event(progress.Event{
-					ID:     name,
+				s.events.On(progress.Event{
+					ID:     "Image " + service.Image,
 					Status: progress.Done,
 					Text:   "Skipped - Image is already present locally",
 				})
@@ -95,20 +95,15 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 		}
 
 		if service.Build != nil && opts.IgnoreBuildable {
-			w.Event(progress.Event{
-				ID:     name,
+			s.events.On(progress.Event{
+				ID:     "Image " + service.Image,
 				Status: progress.Done,
 				Text:   "Skipped - Image can be built",
 			})
 			continue
 		}
 
-		if s, ok := imagesBeingPulled[service.Image]; ok {
-			w.Event(progress.Event{
-				ID:     name,
-				Status: progress.Done,
-				Text:   fmt.Sprintf("Skipped - Image is already being pulled by %v", s),
-			})
+		if _, ok := imagesBeingPulled[service.Image]; ok {
 			continue
 		}
 
@@ -116,7 +111,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 
 		idx := i
 		eg.Go(func() error {
-			_, err := s.pullServiceImage(ctx, service, w, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
+			_, err := s.pullServiceImage(ctx, service, opts.Quiet, project.Environment["DOCKER_DEFAULT_PLATFORM"])
 			if err != nil {
 				pullErrors[idx] = err
 				if service.Build != nil {
@@ -124,8 +119,8 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 				}
 				if !opts.IgnoreFailures && service.Build == nil {
 					if s.dryRun {
-						w.Event(progress.Event{
-							ID:     name,
+						s.events.On(progress.Event{
+							ID:     "Image " + service.Image,
 							Status: progress.Error,
 							Text:   fmt.Sprintf(" - Pull error for image: %s", service.Image),
 						})
@@ -142,7 +137,7 @@ func (s *composeService) pull(ctx context.Context, project *types.Project, opts
 	err = eg.Wait()
 
 	if len(mustBuild) > 0 {
-		w.TailMsgf("WARNING: Some service image(s) must be built from source by running:\n    docker compose build %s", strings.Join(mustBuild, " "))
+		logrus.Warnf("WARNING: Some service image(s) must be built from source by running:\n    docker compose build %s", strings.Join(mustBuild, " "))
 	}
 
 	if err != nil {
@@ -177,12 +172,9 @@ func getUnwrappedErrorMessage(err error) string {
 	return err.Error()
 }
 
-func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, w progress.Writer, quietPull bool, defaultPlatform string) (string, error) {
-	w.Event(progress.Event{
-		ID:     service.Name,
-		Status: progress.Working,
-		Text:   "Pulling",
-	})
+func (s *composeService) pullServiceImage(ctx context.Context, service types.ServiceConfig, quietPull bool, defaultPlatform string) (string, error) {
+	resource := "Image " + service.Image
+	s.events.On(progress.PullingEvent(service.Image))
 	ref, err := reference.ParseNormalizedNamed(service.Image)
 	if err != nil {
 		return "", err
@@ -204,8 +196,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
 	})
 
 	if ctx.Err() != nil {
-		w.Event(progress.Event{
-			ID:         service.Name,
+		s.events.On(progress.Event{
+			ID:         resource,
 			Status:     progress.Warning,
 			StatusText: "Interrupted",
 		})
@@ -215,8 +207,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
 	// check if has error and the service has a build section
 	// then the status should be warning instead of error
 	if err != nil && service.Build != nil {
-		w.Event(progress.Event{
-			ID:         service.Name,
+		s.events.On(progress.Event{
+			ID:         resource,
 			Status:     progress.Warning,
 			Text:       "Warning",
 			StatusText: getUnwrappedErrorMessage(err),
@@ -225,8 +217,8 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
 	}
 
 	if err != nil {
-		w.Event(progress.Event{
-			ID:         service.Name,
+		s.events.On(progress.Event{
+			ID:         resource,
 			Status:     progress.Error,
 			Text:       "Error",
 			StatusText: getUnwrappedErrorMessage(err),
@@ -247,14 +239,10 @@ func (s *composeService) pullServiceImage(ctx context.Context, service types.Ser
 			return "", errors.New(jm.Error.Message)
 		}
 		if !quietPull {
-			toPullProgressEvent(service.Name, jm, w)
+			toPullProgressEvent(resource, jm, s.events)
 		}
 	}
-	w.Event(progress.Event{
-		ID:     service.Name,
-		Status: progress.Done,
-		Text:   "Pulled",
-	})
+	s.events.On(progress.PulledEvent(service.Image))
 
 	inspected, err := s.apiClient().ImageInspect(ctx, service.Image)
 	if err != nil {
@@ -320,37 +308,34 @@ func (s *composeService) pullRequiredImages(ctx context.Context, project *types.
 		return nil
 	}
 
-	return progress.Run(ctx, func(ctx context.Context) error {
-		w := progress.ContextWriter(ctx)
-		eg, ctx := errgroup.WithContext(ctx)
-		eg.SetLimit(s.maxConcurrency)
-		pulledImages := map[string]api.ImageSummary{}
-		var mutex sync.Mutex
-		for name, service := range needPull {
-			eg.Go(func() error {
-				id, err := s.pullServiceImage(ctx, service, w, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"])
-				mutex.Lock()
-				defer mutex.Unlock()
-				pulledImages[name] = api.ImageSummary{
-					ID:          id,
-					Repository:  service.Image,
-					LastTagTime: time.Now(),
-				}
-				if err != nil && isServiceImageToBuild(service, project.Services) {
-					// image can be built, so we can ignore pull failure
-					return nil
-				}
-				return err
-			})
-		}
-		err := eg.Wait()
-		for i, service := range needPull {
-			if pulledImages[i].ID != "" {
-				images[service.Image] = pulledImages[i]
+	eg, ctx := errgroup.WithContext(ctx)
+	eg.SetLimit(s.maxConcurrency)
+	pulledImages := map[string]api.ImageSummary{}
+	var mutex sync.Mutex
+	for name, service := range needPull {
+		eg.Go(func() error {
+			id, err := s.pullServiceImage(ctx, service, quietPull, project.Environment["DOCKER_DEFAULT_PLATFORM"])
+			mutex.Lock()
+			defer mutex.Unlock()
+			pulledImages[name] = api.ImageSummary{
+				ID:          id,
+				Repository:  service.Image,
+				LastTagTime: time.Now(),
+			}
+			if err != nil && isServiceImageToBuild(service, project.Services) {
+				// image can be built, so we can ignore pull failure
+				return nil
 			}
+			return err
+		})
+	}
+	err := eg.Wait()
+	for i, service := range needPull {
+		if pulledImages[i].ID != "" {
+			images[service.Image] = pulledImages[i]
 		}
-		return err
-	}, s.stdinfo())
+	}
+	return err
 }
 
 func mustPull(service types.ServiceConfig, images map[string]api.ImageSummary) (bool, error) {
@@ -414,7 +399,7 @@ const (
 	PullCompletePhase      = "Pull complete"
 )
 
-func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.Writer) {
+func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events progress.EventProcessor) {
 	if jm.ID == "" || jm.Progress == nil {
 		return
 	}
@@ -456,7 +441,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.W
 		text = jm.Error.Message
 	}
 
-	w.Event(progress.Event{
+	events.On(progress.Event{
 		ID:         jm.ID,
 		ParentID:   parent,
 		Current:    current,
diff --git a/pkg/compose/push.go b/pkg/compose/push.go
index 8ae66bc5..54d4ef64 100644
--- a/pkg/compose/push.go
+++ b/pkg/compose/push.go
@@ -40,22 +40,21 @@ func (s *composeService) Push(ctx context.Context, project *types.Project, optio
 	if options.Quiet {
 		return s.push(ctx, project, options)
 	}
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.push(ctx, project, options)
-	}, s.stdinfo(), "Pushing")
+	}, "push", s.events)
 }
 
 func (s *composeService) push(ctx context.Context, project *types.Project, options api.PushOptions) error {
 	eg, ctx := errgroup.WithContext(ctx)
 	eg.SetLimit(s.maxConcurrency)
 
-	w := progress.ContextWriter(ctx)
 	for _, service := range project.Services {
 		if service.Build == nil || service.Image == "" {
 			if options.ImageMandatory && service.Image == "" && service.Provider == nil {
 				return fmt.Errorf("%q attribute is mandatory to push an image for service %q", "service.image", service.Name)
 			}
-			w.Event(progress.Event{
+			s.events.On(progress.Event{
 				ID:     service.Name,
 				Status: progress.Done,
 				Text:   "Skipped",
@@ -69,12 +68,16 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
 
 		for _, tag := range tags {
 			eg.Go(func() error {
-				err := s.pushServiceImage(ctx, tag, w, options.Quiet)
+				s.events.On(progress.NewEvent(tag, progress.Working, "Pushing"))
+				err := s.pushServiceImage(ctx, tag, options.Quiet)
 				if err != nil {
 					if !options.IgnoreFailures {
+						s.events.On(progress.NewEvent(tag, progress.Error, err.Error()))
 						return err
 					}
-					w.TailMsgf("Pushing %s: %s", service.Name, err.Error())
+					s.events.On(progress.NewEvent(tag, progress.Warning, err.Error()))
+				} else {
+					s.events.On(progress.NewEvent(tag, progress.Done, "Pushed"))
 				}
 				return nil
 			})
@@ -83,7 +86,7 @@ func (s *composeService) push(ctx context.Context, project *types.Project, optio
 	return eg.Wait()
 }
 
-func (s *composeService) pushServiceImage(ctx context.Context, tag string, w progress.Writer, quietPush bool) error {
+func (s *composeService) pushServiceImage(ctx context.Context, tag string, quietPush bool) error {
 	ref, err := reference.ParseNormalizedNamed(tag)
 	if err != nil {
 		return err
@@ -119,14 +122,14 @@ func (s *composeService) pushServiceImage(ctx context.Context, tag string, w pro
 		}
 
 		if !quietPush {
-			toPushProgressEvent(tag, jm, w)
+			toPushProgressEvent(tag, jm, s.events)
 		}
 	}
 
 	return nil
 }
 
-func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.Writer) {
+func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, events progress.EventProcessor) {
 	if jm.ID == "" {
 		// skipped
 		return
@@ -157,8 +160,9 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.W
 		}
 	}
 
-	w.Event(progress.Event{
-		ID:         fmt.Sprintf("Pushing %s: %s", prefix, jm.ID),
+	events.On(progress.Event{
+		ParentID:   prefix,
+		ID:         jm.ID,
 		Text:       jm.Status,
 		Status:     status,
 		Current:    current,
diff --git a/pkg/compose/remove.go b/pkg/compose/remove.go
index 64ce5c4c..a41645c1 100644
--- a/pkg/compose/remove.go
+++ b/pkg/compose/remove.go
@@ -92,24 +92,23 @@ func (s *composeService) Remove(ctx context.Context, projectName string, options
 			return nil
 		}
 	}
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.remove(ctx, stoppedContainers, options)
-	}, s.stdinfo(), "Removing")
+	}, "remove", s.events)
 }
 
 func (s *composeService) remove(ctx context.Context, containers Containers, options api.RemoveOptions) error {
-	w := progress.ContextWriter(ctx)
 	eg, ctx := errgroup.WithContext(ctx)
 	for _, ctr := range containers {
 		eg.Go(func() error {
 			eventName := getContainerProgressName(ctr)
-			w.Event(progress.RemovingEvent(eventName))
+			s.events.On(progress.RemovingEvent(eventName))
 			err := s.apiClient().ContainerRemove(ctx, ctr.ID, container.RemoveOptions{
 				RemoveVolumes: options.Volumes,
 				Force:         options.Force,
 			})
 			if err == nil {
-				w.Event(progress.RemovedEvent(eventName))
+				s.events.On(progress.RemovedEvent(eventName))
 			}
 			return err
 		})
diff --git a/pkg/compose/restart.go b/pkg/compose/restart.go
index c8714be7..43f54d85 100644
--- a/pkg/compose/restart.go
+++ b/pkg/compose/restart.go
@@ -29,9 +29,9 @@ import (
 )
 
 func (s *composeService) Restart(ctx context.Context, projectName string, options api.RestartOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.restart(ctx, strings.ToLower(projectName), options)
-	}, s.stdinfo(), "Restarting")
+	}, "restart", s.events)
 }
 
 func (s *composeService) restart(ctx context.Context, projectName string, options api.RestartOptions) error { //nolint:gocyclo
@@ -75,7 +75,6 @@ func (s *composeService) restart(ctx context.Context, projectName string, option
 		}
 	}
 
-	w := progress.ContextWriter(ctx)
 	return InDependencyOrder(ctx, project, func(c context.Context, service string) error {
 		config := project.Services[service]
 		err = s.waitDependencies(ctx, project, service, config.DependsOn, containers, 0)
@@ -94,13 +93,13 @@ func (s *composeService) restart(ctx context.Context, projectName string, option
 					}
 				}
 				eventName := getContainerProgressName(ctr)
-				w.Event(progress.RestartingEvent(eventName))
+				s.events.On(progress.RestartingEvent(eventName))
 				timeout := utils.DurationSecondToInt(options.Timeout)
 				err = s.apiClient().ContainerRestart(ctx, ctr.ID, container.StopOptions{Timeout: timeout})
 				if err != nil {
 					return err
 				}
-				w.Event(progress.StartedEvent(eventName))
+				s.events.On(progress.StartedEvent(eventName))
 				for _, hook := range def.PostStart {
 					err = s.runHook(ctx, ctr, def, hook, nil)
 					if err != nil {
diff --git a/pkg/compose/run.go b/pkg/compose/run.go
index 0e454e6a..0f702704 100644
--- a/pkg/compose/run.go
+++ b/pkg/compose/run.go
@@ -67,7 +67,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
 
 	err = progress.Run(ctx, func(ctx context.Context) error {
 		return s.startDependencies(ctx, project, opts)
-	}, s.stdinfo())
+	}, "run", s.events)
 	if err != nil {
 		return "", err
 	}
@@ -97,7 +97,9 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
 		Add(api.SlugLabel, slug).
 		Add(api.OneoffLabel, "True")
 
-	if err := s.ensureImagesExists(ctx, project, opts.Build, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img
+	// Only ensure image exists for the target service, dependencies were already handled by startDependencies
+	buildOpts := prepareBuildOptions(opts)
+	if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img
 		return "", err
 	}
 
@@ -147,6 +149,16 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project,
 	return created.ID, err
 }
 
+func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions {
+	if opts.Build == nil {
+		return nil
+	}
+	// Create a copy of build options and restrict to only the target service
+	buildOptsCopy := *opts.Build
+	buildOptsCopy.Services = []string{opts.Service}
+	return &buildOptsCopy
+}
+
 func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts api.RunOptions) {
 	service.Tty = opts.Tty
 	service.StdinOpen = opts.Interactive
diff --git a/pkg/compose/scale.go b/pkg/compose/scale.go
index 0f3cd695..124c63e9 100644
--- a/pkg/compose/scale.go
+++ b/pkg/compose/scale.go
@@ -31,5 +31,5 @@ func (s *composeService) Scale(ctx context.Context, project *types.Project, opti
 			return err
 		}
 		return s.start(ctx, project.Name, api.StartOptions{Project: project, Services: options.Services}, nil)
-	}), s.stdinfo())
+	}), "scale", s.events)
 }
diff --git a/pkg/compose/start.go b/pkg/compose/start.go
index b0bde1f6..ad691191 100644
--- a/pkg/compose/start.go
+++ b/pkg/compose/start.go
@@ -33,7 +33,7 @@ import (
 func (s *composeService) Start(ctx context.Context, projectName string, options api.StartOptions) error {
 	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.start(ctx, strings.ToLower(projectName), options, nil)
-	}, s.stdinfo())
+	}, "start", s.events)
 }
 
 func (s *composeService) start(ctx context.Context, projectName string, options api.StartOptions, listener api.ContainerEventListener) error {
diff --git a/pkg/compose/stop.go b/pkg/compose/stop.go
index 8a9cf5aa..de21ad69 100644
--- a/pkg/compose/stop.go
+++ b/pkg/compose/stop.go
@@ -26,9 +26,9 @@ import (
 )
 
 func (s *composeService) Stop(ctx context.Context, projectName string, options api.StopOptions) error {
-	return progress.RunWithTitle(ctx, func(ctx context.Context) error {
+	return progress.Run(ctx, func(ctx context.Context) error {
 		return s.stop(ctx, strings.ToLower(projectName), options, nil)
-	}, s.stdinfo(), "Stopping")
+	}, "stop", s.events)
 }
 
 func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions, event api.ContainerEventListener) error {
@@ -49,12 +49,11 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
 		options.Services = project.ServiceNames()
 	}
 
-	w := progress.ContextWriter(ctx)
 	return InReverseDependencyOrder(ctx, project, func(c context.Context, service string) error {
 		if !slices.Contains(options.Services, service) {
 			return nil
 		}
 		serv := project.Services[service]
-		return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
+		return s.stopContainers(ctx, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event)
 	})
 }
diff --git a/pkg/compose/up.go b/pkg/compose/up.go
index 84d51852..40d866d8 100644
--- a/pkg/compose/up.go
+++ b/pkg/compose/up.go
@@ -49,7 +49,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 			return s.start(ctx, project.Name, options.Start, nil)
 		}
 		return nil
-	}), s.stdinfo())
+	}), "up", s.events)
 	if err != nil {
 		return err
 	}
@@ -128,12 +128,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 			first = false
 			fmt.Println("Gracefully Stopping... press Ctrl+C again to force")
 			eg.Go(func() error {
-				err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error {
-					return s.stop(c, project.Name, api.StopOptions{
-						Services: options.Create.Services,
-						Project:  project,
-					}, printer.HandleEvent)
-				}, s.stdinfo(), logConsumer)
+				err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{
+					Services: options.Create.Services,
+					Project:  project,
+				}, printer.HandleEvent)
 				appendErr(err)
 				return nil
 			})
@@ -209,12 +207,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
 				exitCode = event.ExitCode
 				_, _ = fmt.Fprintln(s.stdinfo(), progress.ErrorColor("Aborting on container exit..."))
 				eg.Go(func() error {
-					err := progress.RunWithLog(context.WithoutCancel(globalCtx), func(c context.Context) error {
-						return s.stop(c, project.Name, api.StopOptions{
-							Services: options.Create.Services,
-							Project:  project,
-						}, printer.HandleEvent)
-					}, s.stdinfo(), logConsumer)
+					err = s.stop(context.WithoutCancel(globalCtx), project.Name, api.StopOptions{
+						Services: options.Create.Services,
+						Project:  project,
+					}, printer.HandleEvent)
 					appendErr(err)
 					return nil
 				})
diff --git a/pkg/progress/event.go b/pkg/progress/event.go
index b2fdcb6e..4a9f6c95 100644
--- a/pkg/progress/event.go
+++ b/pkg/progress/event.go
@@ -16,26 +16,11 @@
 
 package progress
 
-import (
-	"time"
-)
+import "context"
 
 // EventStatus indicates the status of an action
 type EventStatus int
 
-func (s EventStatus) colorFn() colorFunc {
-	switch s {
-	case Done:
-		return SuccessColor
-	case Warning:
-		return WarningColor
-	case Error:
-		return ErrorColor
-	default:
-		return nocolor
-	}
-}
-
 const (
 	// Working means that the current task is working
 	Working EventStatus = iota
@@ -47,6 +32,30 @@ const (
 	Error
 )
 
+const (
+	StatusError      = "Error"
+	StatusCreating   = "Creating"
+	StatusStarting   = "Starting"
+	StatusStarted    = "Started"
+	StatusWaiting    = "Waiting"
+	StatusHealthy    = "Healthy"
+	StatusExited     = "Exited"
+	StatusRestarting = "Restarting"
+	StatusRestarted  = "Restarted"
+	StatusRunning    = "Running"
+	StatusCreated    = "Created"
+	StatusStopping   = "Stopping"
+	StatusStopped    = "Stopped"
+	StatusKilling    = "Killing"
+	StatusKilled     = "Killed"
+	StatusRemoving   = "Removing"
+	StatusRemoved    = "Removed"
+	StatusBuilding   = "Building"
+	StatusBuilt      = "Built"
+	StatusPulling    = "Pulling"
+	StatusPulled     = "Pulled"
+)
+
 // Event represents a progress event.
 type Event struct {
 	ID         string
@@ -56,11 +65,7 @@ type Event struct {
 	StatusText string
 	Current    int64
 	Percent    int
-
-	Total     int64
-	startTime time.Time
-	endTime   time.Time
-	spinner   *Spinner
+	Total      int64
 }
 
 // ErrorMessageEvent creates a new Error Event with message
@@ -70,97 +75,107 @@ func ErrorMessageEvent(id string, msg string) Event {
 
 // ErrorEvent creates a new Error Event
 func ErrorEvent(id string) Event {
-	return NewEvent(id, Error, "Error")
+	return NewEvent(id, Error, StatusError)
 }
 
 // CreatingEvent creates a new Create in progress Event
 func CreatingEvent(id string) Event {
-	return NewEvent(id, Working, "Creating")
+	return NewEvent(id, Working, StatusCreating)
 }
 
 // StartingEvent creates a new Starting in progress Event
 func StartingEvent(id string) Event {
-	return NewEvent(id, Working, "Starting")
+	return NewEvent(id, Working, StatusStarting)
 }
 
 // StartedEvent creates a new Started in progress Event
 func StartedEvent(id string) Event {
-	return NewEvent(id, Done, "Started")
+	return NewEvent(id, Done, StatusStarted)
 }
 
 // Waiting creates a new waiting event
 func Waiting(id string) Event {
-	return NewEvent(id, Working, "Waiting")
+	return NewEvent(id, Working, StatusWaiting)
 }
 
 // Healthy creates a new healthy event
 func Healthy(id string) Event {
-	return NewEvent(id, Done, "Healthy")
+	return NewEvent(id, Done, StatusHealthy)
 }
 
 // Exited creates a new exited event
 func Exited(id string) Event {
-	return NewEvent(id, Done, "Exited")
+	return NewEvent(id, Done, StatusExited)
 }
 
 // RestartingEvent creates a new Restarting in progress Event
 func RestartingEvent(id string) Event {
-	return NewEvent(id, Working, "Restarting")
+	return NewEvent(id, Working, StatusRestarting)
 }
 
 // RestartedEvent creates a new Restarted in progress Event
 func RestartedEvent(id string) Event {
-	return NewEvent(id, Done, "Restarted")
+	return NewEvent(id, Done, StatusRestarted)
 }
 
 // RunningEvent creates a new Running in progress Event
 func RunningEvent(id string) Event {
-	return NewEvent(id, Done, "Running")
+	return NewEvent(id, Done, StatusRunning)
 }
 
 // CreatedEvent creates a new Created (done) Event
 func CreatedEvent(id string) Event {
-	return NewEvent(id, Done, "Created")
+	return NewEvent(id, Done, StatusCreated)
 }
 
 // StoppingEvent creates a new Stopping in progress Event
 func StoppingEvent(id string) Event {
-	return NewEvent(id, Working, "Stopping")
+	return NewEvent(id, Working, StatusStopping)
 }
 
 // StoppedEvent creates a new Stopping in progress Event
 func StoppedEvent(id string) Event {
-	return NewEvent(id, Done, "Stopped")
+	return NewEvent(id, Done, StatusStopped)
 }
 
 // KillingEvent creates a new Killing in progress Event
 func KillingEvent(id string) Event {
-	return NewEvent(id, Working, "Killing")
+	return NewEvent(id, Working, StatusKilling)
 }
 
 // KilledEvent creates a new Killed in progress Event
 func KilledEvent(id string) Event {
-	return NewEvent(id, Done, "Killed")
+	return NewEvent(id, Done, StatusKilled)
 }
 
 // RemovingEvent creates a new Removing in progress Event
 func RemovingEvent(id string) Event {
-	return NewEvent(id, Working, "Removing")
+	return NewEvent(id, Working, StatusRemoving)
 }
 
 // RemovedEvent creates a new removed (done) Event
 func RemovedEvent(id string) Event {
-	return NewEvent(id, Done, "Removed")
+	return NewEvent(id, Done, StatusRemoved)
 }
 
 // BuildingEvent creates a new Building in progress Event
 func BuildingEvent(id string) Event {
-	return NewEvent(id, Working, "Building")
+	return NewEvent("Image "+id, Working, StatusBuilding)
 }
 
 // BuiltEvent creates a new built (done) Event
 func BuiltEvent(id string) Event {
-	return NewEvent(id, Done, "Built")
+	return NewEvent("Image "+id, Done, StatusBuilt)
+}
+
+// PullingEvent creates a new pulling (in progress) Event
+func PullingEvent(id string) Event {
+	return NewEvent("Image "+id, Working, StatusPulling)
+}
+
+// PulledEvent creates a new pulled (done) Event
+func PulledEvent(id string) Event {
+	return NewEvent("Image "+id, Done, StatusPulled)
 }
 
 // SkippedEvent creates a new Skipped Event
@@ -181,30 +196,12 @@ func NewEvent(id string, status EventStatus, statusText string) Event {
 	}
 }
 
-func (e *Event) stop() {
-	e.endTime = time.Now()
-	e.spinner.Stop()
-}
-
-func (e *Event) hasMore() {
-	e.spinner.Restart()
-}
-
-var (
-	spinnerDone    = "✔"
-	spinnerWarning = "!"
-	spinnerError   = "✘"
-)
-
-func (e *Event) Spinner() any {
-	switch e.Status {
-	case Done:
-		return SuccessColor(spinnerDone)
-	case Warning:
-		return WarningColor(spinnerWarning)
-	case Error:
-		return ErrorColor(spinnerError)
-	default:
-		return CountColor(e.spinner.String())
-	}
+// EventProcessor is notified about Compose operations and tasks
+type EventProcessor interface {
+	// Start is triggered as a Compose operation is starting with context
+	Start(ctx context.Context, operation string)
+	// On notify about (sub)task and progress processing operation
+	On(events ...Event)
+	// Done is triggered as a Compose operation completed
+	Done(operation string, success bool)
 }
diff --git a/pkg/progress/json.go b/pkg/progress/json.go
index 219eac9b..81601eab 100644
--- a/pkg/progress/json.go
+++ b/pkg/progress/json.go
@@ -23,9 +23,14 @@ import (
 	"io"
 )
 
+func NewJSONWriter(out io.Writer) EventProcessor {
+	return &jsonWriter{
+		out: out,
+	}
+}
+
 type jsonWriter struct {
 	out    io.Writer
-	done   chan bool
 	dryRun bool
 }
 
@@ -41,13 +46,7 @@ type jsonMessage struct {
 	Percent  int    `json:"percent,omitempty"`
 }
 
-func (p *jsonWriter) Start(ctx context.Context) error {
-	select {
-	case <-ctx.Done():
-		return ctx.Err()
-	case <-p.done:
-		return nil
-	}
+func (p *jsonWriter) Start(ctx context.Context, operation string) {
 }
 
 func (p *jsonWriter) Event(e Event) {
@@ -68,29 +67,11 @@ func (p *jsonWriter) Event(e Event) {
 	}
 }
 
-func (p *jsonWriter) Events(events []Event) {
+func (p *jsonWriter) On(events ...Event) {
 	for _, e := range events {
 		p.Event(e)
 	}
 }
 
-func (p *jsonWriter) TailMsgf(msg string, args ...interface{}) {
-	message := &jsonMessage{
-		DryRun: p.dryRun,
-		Tail:   true,
-		ID:     "",
-		Text:   fmt.Sprintf(msg, args...),
-		Status: "",
-	}
-	marshal, err := json.Marshal(message)
-	if err == nil {
-		_, _ = fmt.Fprintln(p.out, string(marshal))
-	}
-}
-
-func (p *jsonWriter) Stop() {
-	p.done <- true
-}
-
-func (p *jsonWriter) HasMore(bool) {
+func (p *jsonWriter) Done(_ string, _ bool) {
 }
diff --git a/pkg/progress/mixed.go b/pkg/progress/mixed.go
deleted file mode 100644
index d3180516..00000000
--- a/pkg/progress/mixed.go
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
-   Copyright 2020 Docker Compose CLI authors
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-*/
-
-package progress
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/docker/cli/cli/streams"
-	"github.com/docker/compose/v2/pkg/api"
-)
-
-// NewMixedWriter creates a Writer which allows to mix output from progress.Writer with a api.LogConsumer
-func NewMixedWriter(out *streams.Out, consumer api.LogConsumer, dryRun bool) Writer {
-	isTerminal := out.IsTerminal()
-	if Mode != ModeAuto || !isTerminal {
-		return &plainWriter{
-			out:    out,
-			done:   make(chan bool),
-			dryRun: dryRun,
-		}
-	}
-	return &mixedWriter{
-		out:    consumer,
-		done:   make(chan bool),
-		dryRun: dryRun,
-	}
-}
-
-type mixedWriter struct {
-	done   chan bool
-	dryRun bool
-	out    api.LogConsumer
-}
-
-func (p *mixedWriter) Start(ctx context.Context) error {
-	select {
-	case <-ctx.Done():
-		return ctx.Err()
-	case <-p.done:
-		return nil
-	}
-}
-
-func (p *mixedWriter) Event(e Event) {
-	p.out.Status("", fmt.Sprintf("%s %s %s", e.ID, e.Text, SuccessColor(e.StatusText)))
-}
-
-func (p *mixedWriter) Events(events []Event) {
-	for _, e := range events {
-		p.Event(e)
-	}
-}
-
-func (p *mixedWriter) TailMsgf(msg string, args ...interface{}) {
-	msg = fmt.Sprintf(msg, args...)
-	p.out.Status("", WarningColor(msg))
-}
-
-func (p *mixedWriter) Stop() {
-	p.done <- true
-}
diff --git a/pkg/progress/noop.go b/pkg/progress/noop.go
deleted file mode 100644
index 5ef92b61..00000000
--- a/pkg/progress/noop.go
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
-   Copyright 2020 Docker Compose CLI authors
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-*/
-
-package progress
-
-import (
-	"context"
-)
-
-type noopWriter struct{}
-
-func (p *noopWriter) Start(ctx context.Context) error {
-	return nil
-}
-
-func (p *noopWriter) Event(Event) {
-}
-
-func (p *noopWriter) Events([]Event) {
-}
-
-func (p *noopWriter) TailMsgf(_ string, _ ...interface{}) {
-}
-
-func (p *noopWriter) Stop() {
-}
diff --git a/pkg/progress/plain.go b/pkg/progress/plain.go
index 308bcb36..48129a46 100644
--- a/pkg/progress/plain.go
+++ b/pkg/progress/plain.go
@@ -24,19 +24,18 @@ import (
 	"github.com/docker/compose/v2/pkg/api"
 )
 
+func NewPlainWriter(out io.Writer) EventProcessor {
+	return &plainWriter{
+		out: out,
+	}
+}
+
 type plainWriter struct {
 	out    io.Writer
-	done   chan bool
 	dryRun bool
 }
 
-func (p *plainWriter) Start(ctx context.Context) error {
-	select {
-	case <-ctx.Done():
-		return ctx.Err()
-	case <-p.done:
-		return nil
-	}
+func (p *plainWriter) Start(ctx context.Context, operation string) {
 }
 
 func (p *plainWriter) Event(e Event) {
@@ -47,20 +46,11 @@ func (p *plainWriter) Event(e Event) {
 	_, _ = fmt.Fprintln(p.out, prefix, e.ID, e.Text, e.StatusText)
 }
 
-func (p *plainWriter) Events(events []Event) {
+func (p *plainWriter) On(events ...Event) {
 	for _, e := range events {
 		p.Event(e)
 	}
 }
 
-func (p *plainWriter) TailMsgf(msg string, args ...interface{}) {
-	msg = fmt.Sprintf(msg, args...)
-	if p.dryRun {
-		msg = api.DRYRUN_PREFIX + msg
-	}
-	_, _ = fmt.Fprintln(p.out, msg)
-}
-
-func (p *plainWriter) Stop() {
-	p.done <- true
+func (p *plainWriter) Done(_ string, _ bool) {
 }
diff --git a/pkg/progress/progress.go b/pkg/progress/progress.go
new file mode 100644
index 00000000..e7cc9af5
--- /dev/null
+++ b/pkg/progress/progress.go
@@ -0,0 +1,46 @@
+/*
+   Copyright 2020 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package progress
+
+import (
+	"context"
+)
+
+type progressFunc func(context.Context) error
+
+func Run(ctx context.Context, pf progressFunc, operation string, bus EventProcessor) error {
+	bus.Start(ctx, operation)
+	err := pf(ctx)
+	bus.Done(operation, err != nil)
+	return err
+}
+
+const (
+	// ModeAuto detect console capabilities
+	ModeAuto = "auto"
+	// ModeTTY use terminal capability for advanced rendering
+	ModeTTY = "tty"
+	// ModePlain dump raw events to output
+	ModePlain = "plain"
+	// ModeQuiet don't display events
+	ModeQuiet = "quiet"
+	// ModeJSON outputs a machine-readable JSON stream
+	ModeJSON = "json"
+)
+
+// Mode define how progress should be rendered, either as ModePlain or ModeTTY
+var Mode = ModeAuto
diff --git a/pkg/progress/quiet.go b/pkg/progress/quiet.go
index 5c5530d7..31d146db 100644
--- a/pkg/progress/quiet.go
+++ b/pkg/progress/quiet.go
@@ -18,20 +18,17 @@ package progress
 
 import "context"
 
-type quiet struct{}
-
-func (q quiet) Start(_ context.Context) error {
-	return nil
+func NewQuietWriter() EventProcessor {
+	return &quiet{}
 }
 
-func (q quiet) Stop() {
-}
+type quiet struct{}
 
-func (q quiet) Event(_ Event) {
+func (q *quiet) Start(_ context.Context, _ string) {
 }
 
-func (q quiet) Events(_ []Event) {
+func (q *quiet) Done(_ string, _ bool) {
 }
 
-func (q quiet) TailMsgf(_ string, _ ...interface{}) {
+func (q *quiet) On(_ ...Event) {
 }
diff --git a/pkg/progress/tty.go b/pkg/progress/tty.go
index cc4388aa..5545be67 100644
--- a/pkg/progress/tty.go
+++ b/pkg/progress/tty.go
@@ -20,7 +20,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -32,122 +31,173 @@ import (
 	"github.com/morikuni/aec"
 )
 
+// NewTTYWriter creates an EventProcessor that render advanced UI within a terminal.
+// On Start, TUI lists task with a progress timer
+func NewTTYWriter(out io.Writer) EventProcessor {
+	return &ttyWriter{
+		out:   out,
+		tasks: map[string]task{},
+		done:  make(chan bool),
+		mtx:   &sync.Mutex{},
+	}
+}
+
 type ttyWriter struct {
 	out             io.Writer
-	events          map[string]Event
-	eventIDs        []string
+	tasks           map[string]task
 	repeated        bool
 	numLines        int
 	done            chan bool
 	mtx             *sync.Mutex
-	tailEvents      []string
-	dryRun          bool
+	dryRun          bool // FIXME(ndeloof) (re)implement support for dry-run
 	skipChildEvents bool
-	progressTitle   string
+	operation       string
+	ticker          *time.Ticker
 }
 
-func (w *ttyWriter) Start(ctx context.Context) error {
-	ticker := time.NewTicker(100 * time.Millisecond)
-	defer ticker.Stop()
-
-	for {
-		select {
-		case <-ctx.Done():
-			w.print()
-			w.printTailEvents()
-			return ctx.Err()
-		case <-w.done:
-			w.print()
-			w.printTailEvents()
-			return nil
-		case <-ticker.C:
-			w.print()
+type task struct {
+	ID         string
+	parentID   string
+	startTime  time.Time
+	endTime    time.Time
+	text       string
+	status     EventStatus
+	statusText string
+	current    int64
+	percent    int
+	total      int64
+	spinner    *Spinner
+}
+
+func (t *task) stop() {
+	t.endTime = time.Now()
+	t.spinner.Stop()
+}
+
+func (t *task) hasMore() {
+	t.spinner.Restart()
+}
+
+func (w *ttyWriter) Start(ctx context.Context, operation string) {
+	w.ticker = time.NewTicker(100 * time.Millisecond)
+	w.operation = operation
+	go func() {
+		for {
+			select {
+			case <-ctx.Done():
+				// interrupted
+				w.ticker.Stop()
+				return
+			case <-w.done:
+				w.print()
+				w.mtx.Lock()
+				w.ticker.Stop()
+				w.operation = ""
+				w.mtx.Unlock()
+				return
+			case <-w.ticker.C:
+				w.print()
+			}
 		}
-	}
+	}()
 }
 
-func (w *ttyWriter) Stop() {
+func (w *ttyWriter) Done(operation string, success bool) {
 	w.done <- true
 }
 
-func (w *ttyWriter) Event(e Event) {
+func (w *ttyWriter) On(events ...Event) {
 	w.mtx.Lock()
 	defer w.mtx.Unlock()
-	w.event(e)
+	for _, e := range events {
+		if w.operation != "start" && (e.StatusText == "Started" || e.StatusText == "Starting") {
+			// skip those events to avoid mix with container logs
+			continue
+		}
+		w.event(e)
+	}
 }
 
 func (w *ttyWriter) event(e Event) {
-	if !slices.Contains(w.eventIDs, e.ID) {
-		w.eventIDs = append(w.eventIDs, e.ID)
+	// Suspend print while a build is in progress, to avoid collision with buildkit Display
+	if e.StatusText == StatusBuilding {
+		w.ticker.Stop()
+	} else {
+		w.ticker.Reset(100 * time.Millisecond)
 	}
-	if _, ok := w.events[e.ID]; ok {
-		last := w.events[e.ID]
+
+	if last, ok := w.tasks[e.ID]; ok {
 		switch e.Status {
 		case Done, Error, Warning:
-			if last.Status != e.Status {
+			if last.status != e.Status {
 				last.stop()
 			}
 		case Working:
 			last.hasMore()
 		}
-		last.Status = e.Status
-		last.Text = e.Text
-		last.StatusText = e.StatusText
+		last.status = e.Status
+		last.text = e.Text
+		last.statusText = e.StatusText
 		// progress can only go up
-		if e.Total > last.Total {
-			last.Total = e.Total
+		if e.Total > last.total {
+			last.total = e.Total
 		}
-		if e.Current > last.Current {
-			last.Current = e.Current
+		if e.Current > last.current {
+			last.current = e.Current
 		}
-		if e.Percent > last.Percent {
-			last.Percent = e.Percent
+		if e.Percent > last.percent {
+			last.percent = e.Percent
 		}
 		// allow set/unset of parent, but not swapping otherwise prompt is flickering
-		if last.ParentID == "" || e.ParentID == "" {
-			last.ParentID = e.ParentID
+		if last.parentID == "" || e.ParentID == "" {
+			last.parentID = e.ParentID
 		}
-		w.events[e.ID] = last
+		w.tasks[e.ID] = last
 	} else {
-		e.startTime = time.Now()
-		e.spinner = NewSpinner()
+		t := task{
+			ID:         e.ID,
+			parentID:   e.ParentID,
+			startTime:  time.Now(),
+			text:       e.Text,
+			status:     e.Status,
+			statusText: e.StatusText,
+			current:    e.Current,
+			percent:    e.Percent,
+			total:      e.Total,
+			spinner:    NewSpinner(),
+		}
 		if e.Status == Done || e.Status == Error {
-			e.stop()
+			t.stop()
 		}
-		w.events[e.ID] = e
+		w.tasks[e.ID] = t
 	}
+	w.printEvent(e)
 }
 
-func (w *ttyWriter) Events(events []Event) {
-	w.mtx.Lock()
-	defer w.mtx.Unlock()
-	for _, e := range events {
-		w.event(e)
-	}
-}
-
-func (w *ttyWriter) TailMsgf(msg string, args ...interface{}) {
-	w.mtx.Lock()
-	defer w.mtx.Unlock()
-	msgWithPrefix := msg
-	if w.dryRun {
-		msgWithPrefix = strings.TrimSpace(api.DRYRUN_PREFIX + msg)
+func (w *ttyWriter) printEvent(e Event) {
+	if w.operation != "" {
+		// event will be displayed by progress UI on ticker's ticks
+		return
 	}
-	w.tailEvents = append(w.tailEvents, fmt.Sprintf(msgWithPrefix, args...))
-}
 
-func (w *ttyWriter) printTailEvents() {
-	w.mtx.Lock()
-	defer w.mtx.Unlock()
-	for _, msg := range w.tailEvents {
-		_, _ = fmt.Fprintln(w.out, msg)
+	var color colorFunc
+	switch e.Status {
+	case Working:
+		color = SuccessColor
+	case Done:
+		color = SuccessColor
+	case Warning:
+		color = WarningColor
+	case Error:
+		color = ErrorColor
 	}
+	_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, e.Text, color(e.StatusText))
 }
 
-func (w *ttyWriter) print() { //nolint:gocyclo
+func (w *ttyWriter) print() {
 	w.mtx.Lock()
 	defer w.mtx.Unlock()
-	if len(w.eventIDs) == 0 {
+	if len(w.tasks) == 0 {
 		return
 	}
 	terminalWidth := goterm.Width()
@@ -167,43 +217,37 @@ func (w *ttyWriter) print() { //nolint:gocyclo
 		_, _ = fmt.Fprint(w.out, aec.Show)
 	}()
 
-	firstLine := fmt.Sprintf("[+] %s %d/%d", w.progressTitle, numDone(w.events), len(w.events))
-	if w.numLines != 0 && numDone(w.events) == w.numLines {
-		firstLine = DoneColor(firstLine)
-	}
+	firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
 	_, _ = fmt.Fprintln(w.out, firstLine)
 
 	var statusPadding int
-	for _, v := range w.eventIDs {
-		event := w.events[v]
-		l := len(fmt.Sprintf("%s %s", event.ID, event.Text))
+	for _, t := range w.tasks {
+		l := len(fmt.Sprintf("%s %s", t.ID, t.text))
 		if statusPadding < l {
 			statusPadding = l
 		}
-		if event.ParentID != "" {
+		if t.parentID != "" {
 			statusPadding -= 2
 		}
 	}
 
-	if len(w.eventIDs) > goterm.Height()-2 {
+	if len(w.tasks) > goterm.Height()-2 {
 		w.skipChildEvents = true
 	}
 	numLines := 0
-	for _, v := range w.eventIDs {
-		event := w.events[v]
-		if event.ParentID != "" {
+	for _, t := range w.tasks {
+		if t.parentID != "" {
 			continue
 		}
-		line := w.lineText(event, "", terminalWidth, statusPadding, w.dryRun)
+		line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
 		_, _ = fmt.Fprint(w.out, line)
 		numLines++
-		for _, v := range w.eventIDs {
-			ev := w.events[v]
-			if ev.ParentID == event.ID {
+		for _, t := range w.tasks {
+			if t.parentID == t.ID {
 				if w.skipChildEvents {
 					continue
 				}
-				line := w.lineText(ev, "  ", terminalWidth, statusPadding, w.dryRun)
+				line := w.lineText(t, "  ", terminalWidth, statusPadding, w.dryRun)
 				_, _ = fmt.Fprint(w.out, line)
 				numLines++
 			}
@@ -218,12 +262,12 @@ func (w *ttyWriter) print() { //nolint:gocyclo
 	w.numLines = numLines
 }
 
-func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPadding int, dryRun bool) string {
+func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
 	endTime := time.Now()
-	if event.Status != Working {
-		endTime = event.startTime
-		if (event.endTime != time.Time{}) {
-			endTime = event.endTime
+	if t.status != Working {
+		endTime = t.startTime
+		if (t.endTime != time.Time{}) {
+			endTime = t.endTime
 		}
 	}
 	prefix := ""
@@ -231,7 +275,7 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi
 		prefix = PrefixColor(api.DRYRUN_PREFIX)
 	}
 
-	elapsed := endTime.Sub(event.startTime).Seconds()
+	elapsed := endTime.Sub(t.startTime).Seconds()
 
 	var (
 		hideDetails bool
@@ -241,18 +285,17 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi
 	)
 
 	// only show the aggregated progress while the root operation is in-progress
-	if parent := event; parent.Status == Working {
-		for _, v := range w.eventIDs {
-			child := w.events[v]
-			if child.ParentID == parent.ID {
-				if child.Status == Working && child.Total == 0 {
+	if parent := t; parent.status == Working {
+		for _, child := range w.tasks {
+			if child.parentID == parent.ID {
+				if child.status == Working && child.total == 0 {
 					// we don't have totals available for all the child events
 					// so don't show the total progress yet
 					hideDetails = true
 				}
-				total += child.Total
-				current += child.Current
-				completion = append(completion, percentChars[(len(percentChars)-1)*child.Percent/100])
+				total += child.total
+				current += child.current
+				completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
 			}
 		}
 	}
@@ -269,13 +312,13 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi
 			details = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
 		}
 		txt = fmt.Sprintf("%s [%s]%s %s",
-			event.ID,
+			t.ID,
 			SuccessColor(strings.Join(completion, "")),
 			details,
-			event.Text,
+			t.text,
 		)
 	} else {
-		txt = fmt.Sprintf("%s %s", event.ID, event.Text)
+		txt = fmt.Sprintf("%s %s", t.ID, t.text)
 	}
 	textLen := len(txt)
 	padding := statusPadding - textLen
@@ -285,18 +328,18 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi
 	// calculate the max length for the status text, on errors it
 	// is 2-3 lines long and breaks the line formatting
 	maxStatusLen := terminalWidth - textLen - statusPadding - 15
-	status := event.StatusText
+	status := t.statusText
 	// in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
 	if maxStatusLen > 0 && len(status) > maxStatusLen {
 		status = status[:maxStatusLen] + "..."
 	}
 	text := fmt.Sprintf("%s %s%s %s%s %s",
 		pad,
-		event.Spinner(),
+		spinner(t),
 		prefix,
 		txt,
 		strings.Repeat(" ", padding),
-		event.Status.colorFn()(status),
+		colorFn(t.status)(status),
 	)
 	timer := fmt.Sprintf("%.1fs ", elapsed)
 	o := align(text, TimerColor(timer), terminalWidth)
@@ -304,10 +347,42 @@ func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPaddi
 	return o
 }
 
-func numDone(events map[string]Event) int {
+var (
+	spinnerDone    = "✔"
+	spinnerWarning = "!"
+	spinnerError   = "✘"
+)
+
+func spinner(t task) string {
+	switch t.status {
+	case Done:
+		return SuccessColor(spinnerDone)
+	case Warning:
+		return WarningColor(spinnerWarning)
+	case Error:
+		return ErrorColor(spinnerError)
+	default:
+		return CountColor(t.spinner.String())
+	}
+}
+
+func colorFn(s EventStatus) colorFunc {
+	switch s {
+	case Done:
+		return SuccessColor
+	case Warning:
+		return WarningColor
+	case Error:
+		return ErrorColor
+	default:
+		return nocolor
+	}
+}
+
+func numDone(tasks map[string]task) int {
 	i := 0
-	for _, e := range events {
-		if e.Status != Working {
+	for _, t := range tasks {
+		if t.status != Working {
 			i++
 		}
 	}
diff --git a/pkg/progress/writer.go b/pkg/progress/writer.go
deleted file mode 100644
index a9a6d091..00000000
--- a/pkg/progress/writer.go
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
-   Copyright 2020 Docker Compose CLI authors
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-*/
-
-package progress
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"sync"
-
-	"github.com/docker/cli/cli/streams"
-	"golang.org/x/sync/errgroup"
-
-	"github.com/docker/compose/v2/pkg/api"
-)
-
-// Writer can write multiple progress events
-type Writer interface {
-	Start(context.Context) error
-	Stop()
-	Event(Event)
-	Events([]Event)
-	TailMsgf(string, ...interface{})
-}
-
-type writerKey struct{}
-
-// WithContextWriter adds the writer to the context
-func WithContextWriter(ctx context.Context, writer Writer) context.Context {
-	return context.WithValue(ctx, writerKey{}, writer)
-}
-
-// ContextWriter returns the writer from the context
-func ContextWriter(ctx context.Context) Writer {
-	s, ok := ctx.Value(writerKey{}).(Writer)
-	if !ok {
-		return &noopWriter{}
-	}
-	return s
-}
-
-type progressFunc func(context.Context) error
-
-type progressFuncWithStatus func(context.Context) (string, error)
-
-// Run will run a writer and the progress function in parallel
-func Run(ctx context.Context, pf progressFunc, out *streams.Out) error {
-	_, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) {
-		return "", pf(ctx)
-	}, out, "Running")
-	return err
-}
-
-func RunWithLog(ctx context.Context, pf progressFunc, out *streams.Out, logConsumer api.LogConsumer) error {
-	w := NewMixedWriter(out, logConsumer, false) // FIXME(ndeloof) re-implement dry-run
-	eg, _ := errgroup.WithContext(ctx)
-	eg.Go(func() error {
-		return w.Start(context.Background())
-	})
-	eg.Go(func() error {
-		defer w.Stop()
-		ctx = WithContextWriter(ctx, w)
-		err := pf(ctx)
-		return err
-	})
-	return eg.Wait()
-}
-
-func RunWithTitle(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error {
-	_, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) {
-		return "", pf(ctx)
-	}, out, progressTitle)
-	return err
-}
-
-// RunWithStatus will run a writer and the progress function in parallel and return a status
-func RunWithStatus(ctx context.Context, pf progressFuncWithStatus, out *streams.Out, progressTitle string) (string, error) {
-	eg, _ := errgroup.WithContext(ctx)
-	w, err := NewWriter(ctx, out, progressTitle)
-	var result string
-	if err != nil {
-		return "", err
-	}
-	eg.Go(func() error {
-		return w.Start(context.Background())
-	})
-
-	ctx = WithContextWriter(ctx, w)
-
-	eg.Go(func() error {
-		defer w.Stop()
-		s, err := pf(ctx)
-		if err == nil {
-			result = s
-		}
-		return err
-	})
-
-	err = eg.Wait()
-	return result, err
-}
-
-const (
-	// ModeAuto detect console capabilities
-	ModeAuto = "auto"
-	// ModeTTY use terminal capability for advanced rendering
-	ModeTTY = "tty"
-	// ModePlain dump raw events to output
-	ModePlain = "plain"
-	// ModeQuiet don't display events
-	ModeQuiet = "quiet"
-	// ModeJSON outputs a machine-readable JSON stream
-	ModeJSON = "json"
-)
-
-// Mode define how progress should be rendered, either as ModePlain or ModeTTY
-var Mode = ModeAuto
-
-// NewWriter returns a new multi-progress writer
-func NewWriter(ctx context.Context, out *streams.Out, progressTitle string) (Writer, error) {
-	isTerminal := out.IsTerminal()
-	switch Mode {
-	case ModeQuiet:
-		return quiet{}, nil
-	case ModeJSON:
-		return &jsonWriter{
-			out:    out,
-			done:   make(chan bool),
-			dryRun: false, // FIXME(ndeloof) re-implement dry-run
-		}, nil
-	case ModeTTY:
-		return newTTYWriter(out, false, progressTitle)
-	case ModeAuto, "":
-		if isTerminal {
-			return newTTYWriter(out, false, progressTitle)
-		}
-		fallthrough
-	case ModePlain:
-		return &plainWriter{
-			out:    out,
-			done:   make(chan bool),
-			dryRun: false,
-		}, nil
-	}
-	return nil, fmt.Errorf("unknown progress mode: %s", Mode)
-}
-
-func newTTYWriter(out io.Writer, dryRun bool, progressTitle string) (Writer, error) {
-	return &ttyWriter{
-		out:           out,
-		eventIDs:      []string{},
-		events:        map[string]Event{},
-		repeated:      false,
-		done:          make(chan bool),
-		mtx:           &sync.Mutex{},
-		dryRun:        dryRun,
-		progressTitle: progressTitle,
-	}, nil
-}



Test patch:
diff --git a/pkg/compose/convergence_test.go b/pkg/compose/convergence_test.go
index 319c811e..e1d32dcb 100644
--- a/pkg/compose/convergence_test.go
+++ b/pkg/compose/convergence_test.go
@@ -35,7 +35,6 @@ import (
 
 	"github.com/docker/compose/v2/pkg/api"
 	"github.com/docker/compose/v2/pkg/mocks"
-	"github.com/docker/compose/v2/pkg/progress"
 )
 
 func TestContainerName(t *testing.T) {
@@ -87,9 +86,8 @@ func TestServiceLinks(t *testing.T) {
 
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 		s.Links = []string{"db"}
@@ -97,7 +95,7 @@ func TestServiceLinks(t *testing.T) {
 		c := testContainer("db", dbContainerName, false)
 		apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
 
-		links, err := tested.getLinks(context.Background(), testProject, s, 1)
+		links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
 		assert.NilError(t, err)
 
 		assert.Equal(t, len(links), 3)
@@ -111,9 +109,8 @@ func TestServiceLinks(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 		s.Links = []string{"db:db"}
@@ -121,7 +118,7 @@ func TestServiceLinks(t *testing.T) {
 		c := testContainer("db", dbContainerName, false)
 
 		apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
-		links, err := tested.getLinks(context.Background(), testProject, s, 1)
+		links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
 		assert.NilError(t, err)
 
 		assert.Equal(t, len(links), 3)
@@ -135,9 +132,8 @@ func TestServiceLinks(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 		s.Links = []string{"db:dbname"}
@@ -145,7 +141,7 @@ func TestServiceLinks(t *testing.T) {
 		c := testContainer("db", dbContainerName, false)
 		apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
 
-		links, err := tested.getLinks(context.Background(), testProject, s, 1)
+		links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
 		assert.NilError(t, err)
 
 		assert.Equal(t, len(links), 3)
@@ -159,9 +155,8 @@ func TestServiceLinks(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 		s.Links = []string{"db:dbname"}
@@ -170,7 +165,7 @@ func TestServiceLinks(t *testing.T) {
 		c := testContainer("db", dbContainerName, false)
 		apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptions).Return([]container.Summary{c}, nil)
 
-		links, err := tested.getLinks(context.Background(), testProject, s, 1)
+		links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
 		assert.NilError(t, err)
 
 		assert.Equal(t, len(links), 4)
@@ -187,9 +182,8 @@ func TestServiceLinks(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 		s.Links = []string{}
@@ -208,7 +202,7 @@ func TestServiceLinks(t *testing.T) {
 		}
 		apiClient.EXPECT().ContainerList(gomock.Any(), containerListOptionsOneOff).Return([]container.Summary{c}, nil)
 
-		links, err := tested.getLinks(context.Background(), testProject, s, 1)
+		links, err := tested.(*composeService).getLinks(context.Background(), testProject, s, 1)
 		assert.NilError(t, err)
 
 		assert.Equal(t, len(links), 3)
@@ -224,9 +218,8 @@ func TestWaitDependencies(t *testing.T) {
 
 	apiClient := mocks.NewMockAPIClient(mockCtrl)
 	cli := mocks.NewMockCli(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 	cli.EXPECT().Client().Return(apiClient).AnyTimes()
 
 	t.Run("should skip dependencies with scale 0", func(t *testing.T) {
@@ -240,7 +233,7 @@ func TestWaitDependencies(t *testing.T) {
 			"db":    {Condition: ServiceConditionRunningOrHealthy},
 			"redis": {Condition: ServiceConditionRunningOrHealthy},
 		}
-		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
+		assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
 	})
 	t.Run("should skip dependencies with condition service_started", func(t *testing.T) {
 		dbService := types.ServiceConfig{Name: "db", Scale: intPtr(1)}
@@ -253,7 +246,7 @@ func TestWaitDependencies(t *testing.T) {
 			"db":    {Condition: types.ServiceConditionStarted, Required: true},
 			"redis": {Condition: types.ServiceConditionStarted, Required: true},
 		}
-		assert.NilError(t, tested.waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
+		assert.NilError(t, tested.(*composeService).waitDependencies(context.Background(), &project, "", dependencies, nil, 0))
 	})
 }
 
@@ -263,9 +256,8 @@ func TestCreateMobyContainer(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 		cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes()
 		apiClient.EXPECT().DaemonHost().Return("").AnyTimes()
@@ -341,9 +333,9 @@ func TestCreateMobyContainer(t *testing.T) {
 				Aliases:    []string{"bork-test-0"},
 			}))
 
-		_, err := tested.createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
+		_, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
 			Labels: make(types.Labels),
-		}, progress.ContextWriter(context.TODO()))
+		})
 		assert.NilError(t, err)
 	})
 
@@ -352,9 +344,8 @@ func TestCreateMobyContainer(t *testing.T) {
 		defer mockCtrl.Finish()
 		apiClient := mocks.NewMockAPIClient(mockCtrl)
 		cli := mocks.NewMockCli(mockCtrl)
-		tested := composeService{
-			dockerCli: cli,
-		}
+		tested, err := NewComposeService(cli)
+		assert.NilError(t, err)
 		cli.EXPECT().Client().Return(apiClient).AnyTimes()
 		cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes()
 		apiClient.EXPECT().DaemonHost().Return("").AnyTimes()
@@ -428,9 +419,9 @@ func TestCreateMobyContainer(t *testing.T) {
 				NetworkSettings: &container.NetworkSettings{},
 			}, nil)
 
-		_, err := tested.createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
+		_, err = tested.(*composeService).createMobyContainer(context.Background(), &project, service, "test", 0, nil, createOptions{
 			Labels: make(types.Labels),
-		}, progress.ContextWriter(context.TODO()))
+		})
 		assert.NilError(t, err)
 	})
 }
diff --git a/pkg/compose/down_test.go b/pkg/compose/down_test.go
index 58c1ec0b..e9aba1d3 100644
--- a/pkg/compose/down_test.go
+++ b/pkg/compose/down_test.go
@@ -43,9 +43,8 @@ func TestDown(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
 		[]container.Summary{
@@ -91,7 +90,7 @@ func TestDown(t *testing.T) {
 	api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
 	api.EXPECT().NetworkRemove(gomock.Any(), "def456").Return(nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{})
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{})
 	assert.NilError(t, err)
 }
 
@@ -100,9 +99,8 @@ func TestDownWithGivenServices(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
 		[]container.Summary{
@@ -141,7 +139,7 @@ func TestDownWithGivenServices(t *testing.T) {
 	api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil)
 	api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
 		Services: []string{"service1", "not-running-service"},
 	})
 	assert.NilError(t, err)
@@ -152,9 +150,8 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
 		[]container.Summary{
@@ -178,7 +175,7 @@ func TestDownWithSpecifiedServiceButTheServicesAreNotRunning(t *testing.T) {
 			{ID: "def456", Name: "myProject_default", Labels: map[string]string{compose.NetworkLabel: "default"}},
 		}, nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{
 		Services: []string{"not-running-service1", "not-running-service2"},
 	})
 	assert.NilError(t, err)
@@ -189,9 +186,8 @@ func TestDownRemoveOrphans(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(true)).Return(
 		[]container.Summary{
@@ -231,7 +227,7 @@ func TestDownRemoveOrphans(t *testing.T) {
 	api.EXPECT().NetworkInspect(gomock.Any(), "abc123", gomock.Any()).Return(network.Inspect{ID: "abc123"}, nil)
 	api.EXPECT().NetworkRemove(gomock.Any(), "abc123").Return(nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true})
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{RemoveOrphans: true})
 	assert.NilError(t, err)
 }
 
@@ -240,9 +236,8 @@ func TestDownRemoveVolumes(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
 		[]container.Summary{testContainer("service1", "123", false)}, nil)
@@ -264,7 +259,7 @@ func TestDownRemoveVolumes(t *testing.T) {
 
 	api.EXPECT().VolumeRemove(gomock.Any(), "myProject_volume", true).Return(nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Volumes: true})
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Volumes: true})
 	assert.NilError(t, err)
 }
 
@@ -287,9 +282,8 @@ func TestDownRemoveImages(t *testing.T) {
 	}
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).
 		Return([]container.Summary{
@@ -352,7 +346,7 @@ func TestDownRemoveImages(t *testing.T) {
 
 	t.Log("-> docker compose down --rmi=local")
 	opts.Images = "local"
-	err := tested.Down(context.Background(), strings.ToLower(testProject), opts)
+	err = tested.Down(context.Background(), strings.ToLower(testProject), opts)
 	assert.NilError(t, err)
 
 	otherImagesToBeRemoved := []string{
@@ -376,9 +370,8 @@ func TestDownRemoveImages_NoLabel(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	ctr := testContainer("service1", "123", false)
 
@@ -413,7 +406,7 @@ func TestDownRemoveImages_NoLabel(t *testing.T) {
 
 	api.EXPECT().ImageRemove(gomock.Any(), "testproject-service1:latest", image.RemoveOptions{}).Return(nil, nil)
 
-	err := tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Images: "local"})
+	err = tested.Down(context.Background(), strings.ToLower(testProject), compose.DownOptions{Images: "local"})
 	assert.NilError(t, err)
 }
 
diff --git a/pkg/compose/images_test.go b/pkg/compose/images_test.go
index 85a2b577..80006a48 100644
--- a/pkg/compose/images_test.go
+++ b/pkg/compose/images_test.go
@@ -37,9 +37,8 @@ func TestImages(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	ctx := context.Background()
 	args := filters.NewArgs(projectFilter(strings.ToLower(testProject)))
diff --git a/pkg/compose/kill_test.go b/pkg/compose/kill_test.go
index 5877f5ea..b45bb924 100644
--- a/pkg/compose/kill_test.go
+++ b/pkg/compose/kill_test.go
@@ -40,9 +40,8 @@ func TestKillAll(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	name := strings.ToLower(testProject)
 
@@ -65,7 +64,7 @@ func TestKillAll(t *testing.T) {
 	api.EXPECT().ContainerKill(anyCancellableContext(), "456", "").Return(nil)
 	api.EXPECT().ContainerKill(anyCancellableContext(), "789", "").Return(nil)
 
-	err := tested.kill(ctx, name, compose.KillOptions{})
+	err = tested.Kill(ctx, name, compose.KillOptions{})
 	assert.NilError(t, err)
 }
 
@@ -75,9 +74,8 @@ func TestKillSignal(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	name := strings.ToLower(testProject)
 	listOptions := container.ListOptions{
@@ -98,7 +96,7 @@ func TestKillSignal(t *testing.T) {
 		}, nil)
 	api.EXPECT().ContainerKill(anyCancellableContext(), "123", "SIGTERM").Return(nil)
 
-	err := tested.kill(ctx, name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"})
+	err = tested.Kill(ctx, name, compose.KillOptions{Services: []string{serviceName}, Signal: "SIGTERM"})
 	assert.NilError(t, err)
 }
 
diff --git a/pkg/compose/logs_test.go b/pkg/compose/logs_test.go
index 955b5e77..8c6802dc 100644
--- a/pkg/compose/logs_test.go
+++ b/pkg/compose/logs_test.go
@@ -39,9 +39,8 @@ func TestComposeService_Logs_Demux(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	require.NoError(t, err)
 
 	name := strings.ToLower(testProject)
 
@@ -88,7 +87,7 @@ func TestComposeService_Logs_Demux(t *testing.T) {
 	}
 
 	consumer := &testLogConsumer{}
-	err := tested.Logs(ctx, name, consumer, opts)
+	err = tested.Logs(ctx, name, consumer, opts)
 	require.NoError(t, err)
 
 	require.Equal(
@@ -110,9 +109,8 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	require.NoError(t, err)
 
 	name := strings.ToLower(testProject)
 
@@ -159,7 +157,7 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) {
 	opts := compose.LogOptions{
 		Project: proj,
 	}
-	err := tested.Logs(ctx, name, consumer, opts)
+	err = tested.Logs(ctx, name, consumer, opts)
 	require.NoError(t, err)
 
 	require.Equal(t, []string{"hello c1"}, consumer.LogsForContainer("c1"))
diff --git a/pkg/compose/ps_test.go b/pkg/compose/ps_test.go
index ac2230e4..9a62d346 100644
--- a/pkg/compose/ps_test.go
+++ b/pkg/compose/ps_test.go
@@ -34,9 +34,8 @@ func TestPs(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	ctx := context.Background()
 	args := filters.NewArgs(projectFilter(strings.ToLower(testProject)), hasConfigHashLabel())
diff --git a/pkg/compose/stop_test.go b/pkg/compose/stop_test.go
index 9c5d79ef..edc00e34 100644
--- a/pkg/compose/stop_test.go
+++ b/pkg/compose/stop_test.go
@@ -38,9 +38,8 @@ func TestStopTimeout(t *testing.T) {
 	defer mockCtrl.Finish()
 
 	api, cli := prepareMocks(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	assert.NilError(t, err)
 
 	ctx := context.Background()
 	api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
@@ -64,7 +63,7 @@ func TestStopTimeout(t *testing.T) {
 	api.EXPECT().ContainerStop(gomock.Any(), "456", stopConfig).Return(nil)
 	api.EXPECT().ContainerStop(gomock.Any(), "789", stopConfig).Return(nil)
 
-	err := tested.Stop(ctx, strings.ToLower(testProject), compose.StopOptions{
+	err = tested.Stop(ctx, strings.ToLower(testProject), compose.StopOptions{
 		Timeout: &timeout,
 	})
 	assert.NilError(t, err)
diff --git a/pkg/compose/viz_test.go b/pkg/compose/viz_test.go
index ba420d12..a2a1d0e7 100644
--- a/pkg/compose/viz_test.go
+++ b/pkg/compose/viz_test.go
@@ -116,9 +116,8 @@ func TestViz(t *testing.T) {
 	mockCtrl := gomock.NewController(t)
 	defer mockCtrl.Finish()
 	cli := mocks.NewMockCli(mockCtrl)
-	tested := composeService{
-		dockerCli: cli,
-	}
+	tested, err := NewComposeService(cli)
+	require.NoError(t, err)
 
 	ctx := context.Background()
 
diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go
new file mode 100644
index 00000000..c891ca0f
--- /dev/null
+++ b/pkg/e2e/compose_run_build_once_test.go
@@ -0,0 +1,90 @@
+/*
+   Copyright 2020 Docker Compose CLI authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package e2e
+
+import (
+	"strings"
+	"testing"
+
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/icmd"
+)
+
+// TestRunBuildOnce tests that services with pull_policy: build are only built once
+// when using 'docker compose run', even when they are dependencies.
+// This addresses a bug where dependencies were built twice: once in startDependencies
+// and once in ensureImagesExists.
+func TestRunBuildOnce(t *testing.T) {
+	c := NewParallelCLI(t)
+
+	t.Run("dependency with pull_policy build is built only once", func(t *testing.T) {
+		projectName := "e2e-run-build-once-single"
+		res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans")
+		res.Assert(t, icmd.Success)
+
+		res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "run", "--rm", "curl")
+		res.Assert(t, icmd.Success)
+
+		// Count how many times nginx was built by looking for its unique RUN command output
+		nginxBuilds := strings.Count(res.Combined(), "Building nginx at")
+
+		// nginx should build exactly once, not twice
+		assert.Equal(t, nginxBuilds, 1, "nginx dependency should build once, but built %d times", nginxBuilds)
+		assert.Assert(t, strings.Contains(res.Combined(), "curl service"))
+
+		c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--remove-orphans")
+	})
+
+	t.Run("nested dependencies build only once each", func(t *testing.T) {
+		projectName := "e2e-run-build-once-nested"
+		res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--rmi", "local", "--remove-orphans")
+		res.Assert(t, icmd.Success)
+
+		res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "run", "--rm", "app")
+		res.Assert(t, icmd.Success)
+
+		output := res.Combined()
+
+		// Each service should build exactly once
+		dbBuilds := strings.Count(output, "DB built at")
+		apiBuilds := strings.Count(output, "API built at")
+		appBuilds := strings.Count(output, "App built at")
+
+		assert.Equal(t, dbBuilds, 1, "db should build once, built %d times", dbBuilds)
+		assert.Equal(t, apiBuilds, 1, "api should build once, built %d times", apiBuilds)
+		assert.Equal(t, appBuilds, 1, "app should build once, built %d times", appBuilds)
+		assert.Assert(t, strings.Contains(output, "App running"))
+
+		c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--remove-orphans")
+	})
+
+	t.Run("service with no dependencies builds once", func(t *testing.T) {
+		projectName := "e2e-run-build-once-no-deps"
+		res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--rmi", "local", "--remove-orphans")
+		res.Assert(t, icmd.Success)
+
+		res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "run", "--rm", "simple")
+		res.Assert(t, icmd.Success)
+
+		// Should build exactly once
+		simpleBuilds := strings.Count(res.Combined(), "Simple service built at")
+		assert.Equal(t, simpleBuilds, 1, "simple should build once, built %d times", simpleBuilds)
+		assert.Assert(t, strings.Contains(res.Combined(), "Simple service"))
+
+		c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--remove-orphans")
+	})
+}
diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go
index 36a1fd49..1bd53c51 100644
--- a/pkg/e2e/compose_run_test.go
+++ b/pkg/e2e/compose_run_test.go
@@ -187,8 +187,8 @@ func TestLocalComposeRun(t *testing.T) {
 		res.Assert(t, icmd.Success)
 
 		res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/pull.yaml", "run", "--pull", "always", "backend")
-		assert.Assert(t, strings.Contains(res.Combined(), "backend Pulling"), res.Combined())
-		assert.Assert(t, strings.Contains(res.Combined(), "backend Pulled"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "Image nginx  Pulling"), res.Combined())
+		assert.Assert(t, strings.Contains(res.Combined(), "Image nginx  Pulled"), res.Combined())
 	})
 
 	t.Run("compose run --env-from-file", func(t *testing.T) {
diff --git a/pkg/e2e/fixtures/run-test/build-once-nested.yaml b/pkg/e2e/fixtures/run-test/build-once-nested.yaml
new file mode 100644
index 00000000..4972db5a
--- /dev/null
+++ b/pkg/e2e/fixtures/run-test/build-once-nested.yaml
@@ -0,0 +1,32 @@
+services:
+  # Database service with build
+  db:
+    pull_policy: build
+    build:
+      dockerfile_inline: |
+        FROM alpine
+        RUN echo "DB built at $(date)" > /db-build.txt
+        CMD sleep 3600
+
+  # API service that depends on db
+  api:
+    pull_policy: build
+    build:
+      dockerfile_inline: |
+        FROM alpine
+        RUN echo "API built at $(date)" > /api-build.txt
+        CMD sleep 3600
+    depends_on:
+      - db
+
+  # App service that depends on api (which depends on db)
+  app:
+    pull_policy: build
+    build:
+      dockerfile_inline: |
+        FROM alpine
+        RUN echo "App built at $(date)" > /app-build.txt
+        CMD echo "App running"
+    depends_on:
+      - api
+
diff --git a/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml b/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml
new file mode 100644
index 00000000..bf53d951
--- /dev/null
+++ b/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml
@@ -0,0 +1,10 @@
+services:
+  # Simple service with no dependencies
+  simple:
+    pull_policy: build
+    build:
+      dockerfile_inline: |
+        FROM alpine
+        RUN echo "Simple service built at $(date)" > /build.txt
+        CMD echo "Simple service"
+
diff --git a/pkg/e2e/fixtures/run-test/build-once.yaml b/pkg/e2e/fixtures/run-test/build-once.yaml
new file mode 100644
index 00000000..1d86f875
--- /dev/null
+++ b/pkg/e2e/fixtures/run-test/build-once.yaml
@@ -0,0 +1,18 @@
+services:
+  # Service with pull_policy: build to ensure it always rebuilds
+  # This is the key to testing the bug - without the fix, this would build twice
+  nginx:
+    pull_policy: build
+    build:
+      dockerfile_inline: |
+        FROM alpine
+        RUN echo "Building nginx at $(date)" > /build-time.txt
+        CMD sleep 3600
+
+  # Service that depends on nginx
+  curl:
+    image: alpine
+    depends_on:
+      - nginx
+    command: echo "curl service"
+
diff --git a/pkg/e2e/pull_test.go b/pkg/e2e/pull_test.go
index 473b490b..de3541bb 100644
--- a/pkg/e2e/pull_test.go
+++ b/pkg/e2e/pull_test.go
@@ -34,31 +34,15 @@ func TestComposePull(t *testing.T) {
 		res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/simple", "pull")
 		output := res.Combined()
 
-		assert.Assert(t, strings.Contains(output, "simple Pulled"))
-		assert.Assert(t, strings.Contains(output, "another Pulled"))
+		assert.Assert(t, strings.Contains(output, "Image alpine:3.14  Pulled"))
+		assert.Assert(t, strings.Contains(output, "Image alpine:3.15  Pulled"))
 
 		// verify default policy is 'always' for pull command
 		res = c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/simple", "pull")
 		output = res.Combined()
 
-		assert.Assert(t, strings.Contains(output, "simple Pulled"))
-		assert.Assert(t, strings.Contains(output, "another Pulled"))
-	})
-
-	t.Run("Verify a image is pulled once", func(t *testing.T) {
-		// cleanup existing images
-		c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/duplicate-images", "down", "--rmi", "all")
-
-		res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/duplicate-images", "pull")
-		output := res.Combined()
-
-		if strings.Contains(output, "another Pulled") {
-			assert.Assert(t, strings.Contains(output, "another Pulled"))
-			assert.Assert(t, strings.Contains(output, "Skipped - Image is already being pulled by another"))
-		} else {
-			assert.Assert(t, strings.Contains(output, "simple Pulled"))
-			assert.Assert(t, strings.Contains(output, "Skipped - Image is already being pulled by simple"))
-		}
+		assert.Assert(t, strings.Contains(output, "Image alpine:3.14  Pulled"))
+		assert.Assert(t, strings.Contains(output, "Image alpine:3.15  Pulled"))
 	})
 
 	t.Run("Verify skipped pull if image is already present locally", func(t *testing.T) {
@@ -68,9 +52,9 @@ func TestComposePull(t *testing.T) {
 		res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/compose-pull/image-present-locally", "pull")
 		output := res.Combined()
 
-		assert.Assert(t, strings.Contains(output, "simple Skipped - Image is already present locally"))
+		assert.Assert(t, strings.Contains(output, "alpine:3.13.12 Skipped - Image is already present locally"))
 		// image with :latest tag gets pulled regardless if pull_policy: missing or if_not_present
-		assert.Assert(t, strings.Contains(output, "latest Pulled"))
+		assert.Assert(t, strings.Contains(output, "alpine:latest  Pulled"))
 	})
 
 	t.Run("Verify skipped no image to be pulled", func(t *testing.T) {
diff --git a/pkg/progress/json_test.go b/pkg/progress/json_test.go
index fffe535a..d351963e 100644
--- a/pkg/progress/json_test.go
+++ b/pkg/progress/json_test.go
@@ -18,7 +18,6 @@ package progress
 
 import (
 	"bytes"
-	"context"
 	"encoding/json"
 	"testing"
 
@@ -29,7 +28,6 @@ func TestJsonWriter_Event(t *testing.T) {
 	var out bytes.Buffer
 	w := &jsonWriter{
 		out:    &out,
-		done:   make(chan bool),
 		dryRun: true,
 	}
 
@@ -60,30 +58,3 @@ func TestJsonWriter_Event(t *testing.T) {
 	}
 	assert.DeepEqual(t, expected, actual)
 }
-
-func TestJsonWriter_TailMsgf(t *testing.T) {
-	var out bytes.Buffer
-	w := &jsonWriter{
-		out:    &out,
-		done:   make(chan bool),
-		dryRun: false,
-	}
-
-	go func() {
-		_ = w.Start(context.Background())
-	}()
-
-	w.TailMsgf("hello %s", "world")
-
-	w.Stop()
-
-	var actual jsonMessage
-	err := json.Unmarshal(out.Bytes(), &actual)
-	assert.NilError(t, err)
-
-	expected := jsonMessage{
-		Tail: true,
-		Text: "hello world",
-	}
-	assert.DeepEqual(t, expected, actual)
-}
diff --git a/pkg/progress/tty_test.go b/pkg/progress/tty_test.go
deleted file mode 100644
index 3b8c9ba7..00000000
--- a/pkg/progress/tty_test.go
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
-   Copyright 2020 Docker Compose CLI authors
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-*/
-
-package progress
-
-import (
-	"fmt"
-	"sync"
-	"testing"
-	"time"
-
-	"gotest.tools/v3/assert"
-)
-
-func TestLineText(t *testing.T) {
-	now := time.Now()
-	ev := Event{
-		ID:         "id",
-		Text:       "Text",
-		Status:     Working,
-		StatusText: "Status",
-		endTime:    now,
-		startTime:  now,
-		spinner: &Spinner{
-			chars: []string{"."},
-		},
-	}
-
-	lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text))
-
-	out := tty().lineText(ev, "", 50, lineWidth, false)
-	assert.Equal(t, out, " \x1b[33m.\x1b[0m id Text Status                            \x1b[34m0.0s \x1b[0m\n")
-
-	ev.Status = Done
-	out = tty().lineText(ev, "", 50, lineWidth, false)
-	assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m                            \x1b[34m0.0s \x1b[0m\n")
-
-	ev.Status = Error
-	out = tty().lineText(ev, "", 50, lineWidth, false)
-	assert.Equal(t, out, " \x1b[31m\x1b[1m✘\x1b[0m id Text \x1b[31m\x1b[1mStatus\x1b[0m                            \x1b[34m0.0s \x1b[0m\n")
-
-	ev.Status = Warning
-	out = tty().lineText(ev, "", 50, lineWidth, false)
-	assert.Equal(t, out, " \x1b[33m\x1b[1m!\x1b[0m id Text \x1b[33m\x1b[1mStatus\x1b[0m                            \x1b[34m0.0s \x1b[0m\n")
-}
-
-func TestLineTextSingleEvent(t *testing.T) {
-	now := time.Now()
-	ev := Event{
-		ID:         "id",
-		Text:       "Text",
-		Status:     Done,
-		StatusText: "Status",
-		startTime:  now,
-		spinner: &Spinner{
-			chars: []string{"."},
-		},
-	}
-
-	lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text))
-
-	out := tty().lineText(ev, "", 50, lineWidth, false)
-	assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m                            \x1b[34m0.0s \x1b[0m\n")
-}
-
-func TestErrorEvent(t *testing.T) {
-	w := &ttyWriter{
-		events: map[string]Event{},
-		mtx:    &sync.Mutex{},
-	}
-	e := Event{
-		ID:         "id",
-		Text:       "Text",
-		Status:     Working,
-		StatusText: "Working",
-		startTime:  time.Now(),
-		spinner: &Spinner{
-			chars: []string{"."},
-		},
-	}
-	// Fire "Working" event and check end time isn't touched
-	w.Event(e)
-	event, ok := w.events[e.ID]
-	assert.Assert(t, ok)
-	assert.Assert(t, event.endTime.Equal(time.Time{}))
-
-	// Fire "Error" event and check end time is set
-	e.Status = Error
-	w.Event(e)
-	event, ok = w.events[e.ID]
-	assert.Assert(t, ok)
-	assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second)))
-}
-
-func TestWarningEvent(t *testing.T) {
-	w := &ttyWriter{
-		events: map[string]Event{},
-		mtx:    &sync.Mutex{},
-	}
-	e := Event{
-		ID:         "id",
-		Text:       "Text",
-		Status:     Working,
-		StatusText: "Working",
-		startTime:  time.Now(),
-		spinner: &Spinner{
-			chars: []string{"."},
-		},
-	}
-	// Fire "Working" event and check end time isn't touched
-	w.Event(e)
-	event, ok := w.events[e.ID]
-	assert.Assert(t, ok)
-	assert.Assert(t, event.endTime.Equal(time.Time{}))
-
-	// Fire "Warning" event and check end time is set
-	e.Status = Warning
-	w.Event(e)
-	event, ok = w.events[e.ID]
-	assert.Assert(t, ok)
-	assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second)))
-}
-
-func tty() *ttyWriter {
-	tty := &ttyWriter{
-		eventIDs: []string{},
-		events:   map[string]Event{},
-		done:     make(chan bool),
-		mtx:      &sync.Mutex{},
-	}
-	return tty
-}
diff --git a/pkg/progress/writer_test.go b/pkg/progress/writer_test.go
deleted file mode 100644
index 2933811b..00000000
--- a/pkg/progress/writer_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
-   Copyright 2020 Docker Compose CLI authors
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
-*/
-
-package progress
-
-import (
-	"context"
-	"testing"
-
-	"gotest.tools/v3/assert"
-)
-
-func TestNoopWriter(t *testing.T) {
-	todo := context.TODO()
-	writer := ContextWriter(todo)
-
-	assert.Equal(t, writer, &noopWriter{})
-}