Merge changes I5b8522aa,I541aea75

* changes:
  Fix lint errors caught by go test
  Fix .kotlin_builtins glob
diff --git a/android/arch.go b/android/arch.go
index ebe9897..d84c829 100644
--- a/android/arch.go
+++ b/android/arch.go
@@ -330,6 +330,10 @@
 			// Windows builds always prefer 32-bit
 			prefer32 = true
 		}
+		// only the primary arch in the recovery partition
+		if module.InstallInRecovery() {
+			targets = []Target{mctx.Config().Targets[Device][0]}
+		}
 		targets, err := decodeMultilib(multilib, targets, prefer32)
 		if err != nil {
 			mctx.ModuleErrorf("%s", err.Error())
diff --git a/android/testing.go b/android/testing.go
index f5d33e1..ca7e7ce 100644
--- a/android/testing.go
+++ b/android/testing.go
@@ -92,6 +92,16 @@
 	return TestingModule{module}
 }
 
+func (ctx *TestContext) ModuleVariantsForTests(name string) []string {
+	var variants []string
+	ctx.VisitAllModules(func(m blueprint.Module) {
+		if ctx.ModuleName(m) == name {
+			variants = append(variants, ctx.ModuleSubDir(m))
+		}
+	})
+	return variants
+}
+
 // MockFileSystem causes the Context to replace all reads with accesses to the provided map of
 // filenames to contents stored as a byte slice.
 func (ctx *TestContext) MockFileSystem(files map[string][]byte) {
diff --git a/cc/cc_test.go b/cc/cc_test.go
index 1b12ad4..3d162e7 100644
--- a/cc/cc_test.go
+++ b/cc/cc_test.go
@@ -75,16 +75,19 @@
 		toolchain_library {
 			name: "libatomic",
 			vendor_available: true,
+			recovery_available: true,
 		}
 
 		toolchain_library {
 			name: "libcompiler_rt-extras",
 			vendor_available: true,
+			recovery_available: true,
 		}
 
 		toolchain_library {
 			name: "libgcc",
 			vendor_available: true,
+			recovery_available: true,
 		}
 
 		cc_library {
@@ -92,6 +95,7 @@
 			no_libgcc: true,
 			nocrt: true,
 			system_shared_libs: [],
+			recovery_available: true,
 		}
 		llndk_library {
 			name: "libc",
@@ -102,6 +106,7 @@
 			no_libgcc: true,
 			nocrt: true,
 			system_shared_libs: [],
+			recovery_available: true,
 		}
 		llndk_library {
 			name: "libm",
@@ -112,6 +117,7 @@
 			no_libgcc: true,
 			nocrt: true,
 			system_shared_libs: [],
+			recovery_available: true,
 		}
 		llndk_library {
 			name: "libdl",
@@ -124,6 +130,7 @@
 			system_shared_libs: [],
 			stl: "none",
 			vendor_available: true,
+			recovery_available: true,
 		}
 		cc_library {
 			name: "libc++",
@@ -132,6 +139,7 @@
 			system_shared_libs: [],
 			stl: "none",
 			vendor_available: true,
+			recovery_available: true,
 			vndk: {
 				enabled: true,
 				support_system_process: true,
@@ -144,14 +152,17 @@
 			system_shared_libs: [],
 			stl: "none",
 			vendor_available: true,
+			recovery_available: true,
 		}
 
 		cc_object {
 			name: "crtbegin_so",
+			recovery_available: true,
 		}
 
 		cc_object {
 			name: "crtend_so",
+			recovery_available: true,
 		}
 
 		cc_library {
@@ -1651,3 +1662,28 @@
 	}
 
 }
+
+func TestRecovery(t *testing.T) {
+	ctx := testCc(t, `
+		cc_library_shared {
+			name: "librecovery",
+			recovery: true,
+		}
+		cc_library_shared {
+			name: "librecovery32",
+			recovery: true,
+			compile_multilib:"32",
+		}
+	`)
+
+	variants := ctx.ModuleVariantsForTests("librecovery")
+	const arm64 = "android_arm64_armv8-a_recovery_shared"
+	if len(variants) != 1 || !android.InList(arm64, variants) {
+		t.Errorf("variants of librecovery must be \"%s\" only, but was %#v", arm64, variants)
+	}
+
+	variants = ctx.ModuleVariantsForTests("librecovery32")
+	if android.InList(arm64, variants) {
+		t.Errorf("multilib was set to 32 for librecovery32, but its variants has %s.", arm64)
+	}
+}
diff --git a/cmd/multiproduct_kati/Android.bp b/cmd/multiproduct_kati/Android.bp
index 04a5802..13b3679 100644
--- a/cmd/multiproduct_kati/Android.bp
+++ b/cmd/multiproduct_kati/Android.bp
@@ -17,6 +17,7 @@
     deps: [
         "soong-ui-build",
         "soong-ui-logger",
+        "soong-ui-terminal",
         "soong-ui-tracer",
         "soong-zip",
     ],
diff --git a/cmd/multiproduct_kati/main.go b/cmd/multiproduct_kati/main.go
index ab82963..237d384 100644
--- a/cmd/multiproduct_kati/main.go
+++ b/cmd/multiproduct_kati/main.go
@@ -29,6 +29,8 @@
 
 	"android/soong/ui/build"
 	"android/soong/ui/logger"
+	"android/soong/ui/status"
+	"android/soong/ui/terminal"
 	"android/soong/ui/tracer"
 	"android/soong/zip"
 )
@@ -66,98 +68,34 @@
 	ctx     build.Context
 	config  build.Config
 	logFile string
+	action  *status.Action
 }
 
-type Status struct {
-	cur    int
-	total  int
-	failed int
-
-	ctx           build.Context
-	haveBlankLine bool
-	smartTerminal bool
-
-	lock sync.Mutex
-}
-
-func NewStatus(ctx build.Context) *Status {
-	return &Status{
-		ctx:           ctx,
-		haveBlankLine: true,
-		smartTerminal: ctx.IsTerminal(),
-	}
-}
-
-func (s *Status) SetTotal(total int) {
-	s.total = total
-}
-
-func (s *Status) Fail(product string, err error, logFile string) {
-	s.Finish(product)
-
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	if s.smartTerminal && !s.haveBlankLine {
-		fmt.Fprintln(s.ctx.Stdout())
-		s.haveBlankLine = true
+func errMsgFromLog(filename string) string {
+	if filename == "" {
+		return ""
 	}
 
-	s.failed++
-	fmt.Fprintln(s.ctx.Stderr(), "FAILED:", product)
-	s.ctx.Verboseln("FAILED:", product)
-
-	if logFile != "" {
-		data, err := ioutil.ReadFile(logFile)
-		if err == nil {
-			lines := strings.Split(strings.TrimSpace(string(data)), "\n")
-			if len(lines) > errorLeadingLines+errorTrailingLines+1 {
-				lines[errorLeadingLines] = fmt.Sprintf("... skipping %d lines ...",
-					len(lines)-errorLeadingLines-errorTrailingLines)
-
-				lines = append(lines[:errorLeadingLines+1],
-					lines[len(lines)-errorTrailingLines:]...)
-			}
-			for _, line := range lines {
-				fmt.Fprintln(s.ctx.Stderr(), "> ", line)
-				s.ctx.Verboseln(line)
-			}
-		}
+	data, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return ""
 	}
 
-	s.ctx.Print(err)
-}
+	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+	if len(lines) > errorLeadingLines+errorTrailingLines+1 {
+		lines[errorLeadingLines] = fmt.Sprintf("... skipping %d lines ...",
+			len(lines)-errorLeadingLines-errorTrailingLines)
 
-func (s *Status) Finish(product string) {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	s.cur++
-	line := fmt.Sprintf("[%d/%d] %s", s.cur, s.total, product)
-
-	if s.smartTerminal {
-		if max, ok := s.ctx.TermWidth(); ok {
-			if len(line) > max {
-				line = line[:max]
-			}
-		}
-
-		fmt.Fprint(s.ctx.Stdout(), "\r", line, "\x1b[K")
-		s.haveBlankLine = false
-	} else {
-		s.ctx.Println(line)
+		lines = append(lines[:errorLeadingLines+1],
+			lines[len(lines)-errorTrailingLines:]...)
 	}
-}
-
-func (s *Status) Finished() int {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	if !s.haveBlankLine {
-		fmt.Fprintln(s.ctx.Stdout())
-		s.haveBlankLine = true
+	var buf strings.Builder
+	for _, line := range lines {
+		buf.WriteString("> ")
+		buf.WriteString(line)
+		buf.WriteString("\n")
 	}
-	return s.failed
+	return buf.String()
 }
 
 // TODO(b/70370883): This tool uses a lot of open files -- over the default
@@ -194,6 +132,9 @@
 }
 
 func main() {
+	writer := terminal.NewWriter(terminal.StdioImpl{})
+	defer writer.Finish()
+
 	log := logger.New(os.Stderr)
 	defer log.Cleanup()
 
@@ -205,20 +146,24 @@
 	trace := tracer.New(log)
 	defer trace.Close()
 
+	stat := &status.Status{}
+	defer stat.Finish()
+	stat.AddOutput(terminal.NewStatusOutput(writer, ""))
+
 	build.SetupSignals(log, cancel, func() {
 		trace.Close()
 		log.Cleanup()
+		stat.Finish()
 	})
 
 	buildCtx := build.Context{&build.ContextImpl{
-		Context:        ctx,
-		Logger:         log,
-		Tracer:         trace,
-		StdioInterface: build.StdioImpl{},
+		Context: ctx,
+		Logger:  log,
+		Tracer:  trace,
+		Writer:  writer,
+		Status:  stat,
 	}}
 
-	status := NewStatus(buildCtx)
-
 	config := build.NewConfig(buildCtx)
 	if *outDir == "" {
 		name := "multiproduct-" + time.Now().Format("20060102150405")
@@ -303,7 +248,8 @@
 
 	log.Verbose("Got product list: ", products)
 
-	status.SetTotal(len(products))
+	s := buildCtx.Status.StartTool()
+	s.SetTotalActions(len(products))
 
 	var wg sync.WaitGroup
 	productConfigs := make(chan Product, len(products))
@@ -315,8 +261,18 @@
 			var stdLog string
 
 			defer wg.Done()
+
+			action := &status.Action{
+				Description: product,
+				Outputs:     []string{product},
+			}
+			s.StartAction(action)
 			defer logger.Recover(func(err error) {
-				status.Fail(product, err, stdLog)
+				s.FinishAction(status.ActionResult{
+					Action: action,
+					Error:  err,
+					Output: errMsgFromLog(stdLog),
+				})
 			})
 
 			productOutDir := filepath.Join(config.OutDir(), product)
@@ -339,12 +295,14 @@
 			productLog.SetOutput(filepath.Join(productLogDir, "soong.log"))
 
 			productCtx := build.Context{&build.ContextImpl{
-				Context:        ctx,
-				Logger:         productLog,
-				Tracer:         trace,
-				StdioInterface: build.NewCustomStdio(nil, f, f),
-				Thread:         trace.NewThread(product),
+				Context: ctx,
+				Logger:  productLog,
+				Tracer:  trace,
+				Writer:  terminal.NewWriter(terminal.NewCustomStdio(nil, f, f)),
+				Thread:  trace.NewThread(product),
+				Status:  &status.Status{},
 			}}
+			productCtx.Status.AddOutput(terminal.NewStatusOutput(productCtx.Writer, ""))
 
 			productConfig := build.NewConfig(productCtx)
 			productConfig.Environment().Set("OUT_DIR", productOutDir)
@@ -352,7 +310,7 @@
 			productConfig.Lunch(productCtx, product, *buildVariant)
 
 			build.Build(productCtx, productConfig, build.BuildProductConfig)
-			productConfigs <- Product{productCtx, productConfig, stdLog}
+			productConfigs <- Product{productCtx, productConfig, stdLog, action}
 		}(product)
 	}
 	go func() {
@@ -369,7 +327,11 @@
 			for product := range productConfigs {
 				func() {
 					defer logger.Recover(func(err error) {
-						status.Fail(product.config.TargetProduct(), err, product.logFile)
+						s.FinishAction(status.ActionResult{
+							Action: product.action,
+							Error:  err,
+							Output: errMsgFromLog(product.logFile),
+						})
 					})
 
 					defer func() {
@@ -400,7 +362,9 @@
 						}
 					}
 					build.Build(product.ctx, product.config, buildWhat)
-					status.Finish(product.config.TargetProduct())
+					s.FinishAction(status.ActionResult{
+						Action: product.action,
+					})
 				}()
 			}
 		}()
@@ -421,7 +385,5 @@
 		}
 	}
 
-	if count := status.Finished(); count > 0 {
-		log.Fatalln(count, "products failed")
-	}
+	s.Finish()
 }
diff --git a/cmd/soong_ui/Android.bp b/cmd/soong_ui/Android.bp
index f09e42e..4e57bef 100644
--- a/cmd/soong_ui/Android.bp
+++ b/cmd/soong_ui/Android.bp
@@ -17,6 +17,7 @@
     deps: [
         "soong-ui-build",
         "soong-ui-logger",
+        "soong-ui-terminal",
         "soong-ui-tracer",
     ],
     srcs: [
diff --git a/cmd/soong_ui/main.go b/cmd/soong_ui/main.go
index 2ca7ebf..e2f25b8 100644
--- a/cmd/soong_ui/main.go
+++ b/cmd/soong_ui/main.go
@@ -26,6 +26,8 @@
 
 	"android/soong/ui/build"
 	"android/soong/ui/logger"
+	"android/soong/ui/status"
+	"android/soong/ui/terminal"
 	"android/soong/ui/tracer"
 )
 
@@ -44,7 +46,10 @@
 }
 
 func main() {
-	log := logger.New(os.Stderr)
+	writer := terminal.NewWriter(terminal.StdioImpl{})
+	defer writer.Finish()
+
+	log := logger.New(writer)
 	defer log.Cleanup()
 
 	if len(os.Args) < 2 || !(inList("--make-mode", os.Args) ||
@@ -60,16 +65,23 @@
 	trace := tracer.New(log)
 	defer trace.Close()
 
+	stat := &status.Status{}
+	defer stat.Finish()
+	stat.AddOutput(terminal.NewStatusOutput(writer, os.Getenv("NINJA_STATUS")))
+	stat.AddOutput(trace.StatusTracer())
+
 	build.SetupSignals(log, cancel, func() {
 		trace.Close()
 		log.Cleanup()
+		stat.Finish()
 	})
 
 	buildCtx := build.Context{&build.ContextImpl{
-		Context:        ctx,
-		Logger:         log,
-		Tracer:         trace,
-		StdioInterface: build.StdioImpl{},
+		Context: ctx,
+		Logger:  log,
+		Tracer:  trace,
+		Writer:  writer,
+		Status:  stat,
 	}}
 	var config build.Config
 	if os.Args[1] == "--dumpvars-mode" || os.Args[1] == "--dumpvar-mode" {
@@ -78,19 +90,19 @@
 		config = build.NewConfig(buildCtx, os.Args[1:]...)
 	}
 
-	log.SetVerbose(config.IsVerbose())
 	build.SetupOutDir(buildCtx, config)
 
+	logsDir := config.OutDir()
 	if config.Dist() {
-		logsDir := filepath.Join(config.DistDir(), "logs")
-		os.MkdirAll(logsDir, 0777)
-		log.SetOutput(filepath.Join(logsDir, "soong.log"))
-		trace.SetOutput(filepath.Join(logsDir, "build.trace"))
-	} else {
-		log.SetOutput(filepath.Join(config.OutDir(), "soong.log"))
-		trace.SetOutput(filepath.Join(config.OutDir(), "build.trace"))
+		logsDir = filepath.Join(config.DistDir(), "logs")
 	}
 
+	os.MkdirAll(logsDir, 0777)
+	log.SetOutput(filepath.Join(logsDir, "soong.log"))
+	trace.SetOutput(filepath.Join(logsDir, "build.trace"))
+	stat.AddOutput(status.NewVerboseLog(log, filepath.Join(logsDir, "verbose.log")))
+	stat.AddOutput(status.NewErrorLog(log, filepath.Join(logsDir, "error.log")))
+
 	if start, ok := os.LookupEnv("TRACE_BEGIN_SOONG"); ok {
 		if !strings.HasSuffix(start, "N") {
 			if start_time, err := strconv.ParseUint(start, 10, 64); err == nil {
@@ -114,6 +126,17 @@
 	} else if os.Args[1] == "--dumpvars-mode" {
 		dumpVars(buildCtx, config, os.Args[2:])
 	} else {
+		if config.IsVerbose() {
+			writer.Print("! The argument `showcommands` is no longer supported.")
+			writer.Print("! Instead, the verbose log is always written to a compressed file in the output dir:")
+			writer.Print("!")
+			writer.Print(fmt.Sprintf("!   gzip -cd %s/verbose.log.gz | less -R", logsDir))
+			writer.Print("!")
+			writer.Print("! Older versions are saved in verbose.log.#.gz files")
+			writer.Print("")
+			time.Sleep(5 * time.Second)
+		}
+
 		toBuild := build.BuildAll
 		if config.Checkbuild() {
 			toBuild |= build.RunBuildTests
diff --git a/java/droiddoc.go b/java/droiddoc.go
index 6cb8d3c..9ec2be8 100644
--- a/java/droiddoc.go
+++ b/java/droiddoc.go
@@ -34,7 +34,7 @@
 				`$opts $bootclasspathArgs $classpathArgs -sourcepath $sourcepath ` +
 				`-d $outDir -quiet  && ` +
 				`${config.SoongZipCmd} -write_if_changed -d -o $docZip -C $outDir -D $outDir && ` +
-				`${config.SoongZipCmd} -write_if_changed -jar -o $out -C $stubsDir -D $stubsDir`,
+				`${config.SoongZipCmd} -write_if_changed -jar -o $out -C $stubsDir -D $stubsDir $postDoclavaCmds`,
 			CommandDeps: []string{
 				"${config.ZipSyncCmd}",
 				"${config.JavadocCmd}",
@@ -45,7 +45,7 @@
 			Restat:         true,
 		},
 		"outDir", "srcJarDir", "stubsDir", "srcJars", "opts",
-		"bootclasspathArgs", "classpathArgs", "sourcepath", "docZip")
+		"bootclasspathArgs", "classpathArgs", "sourcepath", "docZip", "postDoclavaCmds")
 
 	apiCheck = pctx.AndroidStaticRule("apiCheck",
 		blueprint.RuleParams{
@@ -201,6 +201,16 @@
 	// names of the output files used in args that will be generated
 	Out []string
 
+	// if set to true, collect the values used by the Dev tools and
+	// write them in files packaged with the SDK. Defaults to false.
+	Write_sdk_values *bool
+
+	// index.html under current module will be copied to docs out dir, if not null.
+	Static_doc_index_redirect *string
+
+	// source.properties under current module will be copied to docs out dir, if not null.
+	Static_doc_properties *string
+
 	// a list of files under current module source dir which contains known tags in Java sources.
 	// filegroup or genrule can be included within this property.
 	Knowntags []string
@@ -629,6 +639,14 @@
 	// knowntags may contain filegroup or genrule.
 	android.ExtractSourcesDeps(ctx, d.properties.Knowntags)
 
+	if String(d.properties.Static_doc_index_redirect) != "" {
+		android.ExtractSourceDeps(ctx, d.properties.Static_doc_index_redirect)
+	}
+
+	if String(d.properties.Static_doc_properties) != "" {
+		android.ExtractSourceDeps(ctx, d.properties.Static_doc_properties)
+	}
+
 	if d.checkCurrentApi() {
 		android.ExtractSourceDeps(ctx, d.properties.Check_api.Current.Api_file)
 		android.ExtractSourceDeps(ctx, d.properties.Check_api.Current.Removed_api_file)
@@ -876,6 +894,27 @@
 			opts += " -stubs " + android.PathForModuleOut(ctx, "docs", "stubsDir").String()
 		}
 
+		if Bool(d.properties.Write_sdk_values) {
+			opts += " -sdkvalues " + android.PathForModuleOut(ctx, "docs").String()
+		}
+
+		var postDoclavaCmds string
+		if String(d.properties.Static_doc_index_redirect) != "" {
+			static_doc_index_redirect := ctx.ExpandSource(String(d.properties.Static_doc_index_redirect),
+				"static_doc_index_redirect")
+			implicits = append(implicits, static_doc_index_redirect)
+			postDoclavaCmds += " && cp " + static_doc_index_redirect.String() + " " +
+				android.PathForModuleOut(ctx, "docs", "out", "index.html").String()
+		}
+
+		if String(d.properties.Static_doc_properties) != "" {
+			static_doc_properties := ctx.ExpandSource(String(d.properties.Static_doc_properties),
+				"static_doc_properties")
+			implicits = append(implicits, static_doc_properties)
+			postDoclavaCmds += " && cp " + static_doc_properties.String() + " " +
+				android.PathForModuleOut(ctx, "docs", "out", "source.properties").String()
+		}
+
 		ctx.Build(pctx, android.BuildParams{
 			Rule:            javadoc,
 			Description:     "Droiddoc",
@@ -893,6 +932,7 @@
 				"classpathArgs":     classpathArgs,
 				"sourcepath":        strings.Join(d.Javadoc.sourcepaths.Strings(), ":"),
 				"docZip":            d.Javadoc.docZip.String(),
+				"postDoclavaCmds":   postDoclavaCmds,
 			},
 		})
 	} else {
diff --git a/java/sdk_library.go b/java/sdk_library.go
index e4cfd41..e65af65 100644
--- a/java/sdk_library.go
+++ b/java/sdk_library.go
@@ -46,6 +46,7 @@
 	publicApiStubsTag = dependencyTag{name: "public"}
 	systemApiStubsTag = dependencyTag{name: "system"}
 	testApiStubsTag   = dependencyTag{name: "test"}
+	implLibTag        = dependencyTag{name: "platform"}
 )
 
 type apiScope int
@@ -127,6 +128,7 @@
 	publicApiStubsPath android.Paths
 	systemApiStubsPath android.Paths
 	testApiStubsPath   android.Paths
+	implLibPath        android.Paths
 }
 
 func (module *sdkLibrary) DepsMutator(ctx android.BottomUpMutatorContext) {
@@ -134,24 +136,27 @@
 	ctx.AddDependency(ctx.Module(), publicApiStubsTag, module.stubsName(apiScopePublic))
 	ctx.AddDependency(ctx.Module(), systemApiStubsTag, module.stubsName(apiScopeSystem))
 	ctx.AddDependency(ctx.Module(), testApiStubsTag, module.stubsName(apiScopeTest))
+	ctx.AddDependency(ctx.Module(), implLibTag, module.implName())
 }
 
 func (module *sdkLibrary) GenerateAndroidBuildActions(ctx android.ModuleContext) {
-	// Record the paths to the header jars of the stubs library.
+	// Record the paths to the header jars of the library (stubs and impl).
 	// When this java_sdk_library is dependened from others via "libs" property,
 	// the recorded paths will be returned depending on the link type of the caller.
 	ctx.VisitDirectDeps(func(to android.Module) {
 		otherName := ctx.OtherModuleName(to)
 		tag := ctx.OtherModuleDependencyTag(to)
 
-		if stubs, ok := to.(Dependency); ok {
+		if lib, ok := to.(Dependency); ok {
 			switch tag {
 			case publicApiStubsTag:
-				module.publicApiStubsPath = stubs.HeaderJars()
+				module.publicApiStubsPath = lib.HeaderJars()
 			case systemApiStubsTag:
-				module.systemApiStubsPath = stubs.HeaderJars()
+				module.systemApiStubsPath = lib.HeaderJars()
 			case testApiStubsTag:
-				module.testApiStubsPath = stubs.HeaderJars()
+				module.testApiStubsPath = lib.HeaderJars()
+			case implLibTag:
+				module.implLibPath = lib.HeaderJars()
 			default:
 				ctx.ModuleErrorf("depends on module %q of unknown tag %q", otherName, tag)
 			}
@@ -543,8 +548,10 @@
 // to satisfy SdkLibraryDependency interface
 func (module *sdkLibrary) HeaderJars(linkType linkType) android.Paths {
 	// This module is just a wrapper for the stubs.
-	if linkType == javaSystem || linkType == javaPlatform {
+	if linkType == javaSystem {
 		return module.systemApiStubsPath
+	} else if linkType == javaPlatform {
+		return module.implLibPath
 	} else {
 		return module.publicApiStubsPath
 	}
diff --git a/phony/phony.go b/phony/phony.go
index a39b5d5..0c62e8a 100644
--- a/phony/phony.go
+++ b/phony/phony.go
@@ -23,7 +23,7 @@
 )
 
 func init() {
-	android.RegisterModuleType("phony", phonyFactory)
+	android.RegisterModuleType("phony", PhonyFactory)
 }
 
 type phony struct {
@@ -31,7 +31,7 @@
 	requiredModuleNames []string
 }
 
-func phonyFactory() android.Module {
+func PhonyFactory() android.Module {
 	module := &phony{}
 
 	android.InitAndroidModule(module)
diff --git a/scripts/microfactory.bash b/scripts/microfactory.bash
index 65ba55d..4bb6058 100644
--- a/scripts/microfactory.bash
+++ b/scripts/microfactory.bash
@@ -59,7 +59,7 @@
     BUILDDIR=$(getoutdir) \
       SRCDIR=${TOP} \
       BLUEPRINTDIR=${TOP}/build/blueprint \
-      EXTRA_ARGS="-pkg-path android/soong=${TOP}/build/soong" \
+      EXTRA_ARGS="-pkg-path android/soong=${TOP}/build/soong -pkg-path github.com/golang/protobuf=${TOP}/external/golang-protobuf" \
       build_go $@
 }
 
diff --git a/ui/build/Android.bp b/ui/build/Android.bp
index 1fe5b6f..a48a314 100644
--- a/ui/build/Android.bp
+++ b/ui/build/Android.bp
@@ -30,6 +30,8 @@
     deps: [
         "soong-ui-build-paths",
         "soong-ui-logger",
+        "soong-ui-status",
+        "soong-ui-terminal",
         "soong-ui-tracer",
         "soong-shared",
         "soong-finder",
@@ -62,13 +64,11 @@
     darwin: {
         srcs: [
             "sandbox_darwin.go",
-            "util_darwin.go"
         ],
     },
     linux: {
         srcs: [
             "sandbox_linux.go",
-            "util_linux.go"
         ],
     },
 }
diff --git a/ui/build/build.go b/ui/build/build.go
index acba275..96cfdbb 100644
--- a/ui/build/build.go
+++ b/ui/build/build.go
@@ -105,9 +105,7 @@
 func help(ctx Context, config Config, what int) {
 	cmd := Command(ctx, config, "help.sh", "build/make/help.sh")
 	cmd.Sandbox = dumpvarsSandbox
-	cmd.Stdout = ctx.Stdout()
-	cmd.Stderr = ctx.Stderr()
-	cmd.RunOrFatal()
+	cmd.RunAndPrintOrFatal()
 }
 
 // Build the tree. The 'what' argument can be used to chose which components of
diff --git a/ui/build/config_test.go b/ui/build/config_test.go
index e4eab94..242e3af 100644
--- a/ui/build/config_test.go
+++ b/ui/build/config_test.go
@@ -22,13 +22,14 @@
 	"testing"
 
 	"android/soong/ui/logger"
+	"android/soong/ui/terminal"
 )
 
 func testContext() Context {
 	return Context{&ContextImpl{
-		Context:        context.Background(),
-		Logger:         logger.New(&bytes.Buffer{}),
-		StdioInterface: NewCustomStdio(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}),
+		Context: context.Background(),
+		Logger:  logger.New(&bytes.Buffer{}),
+		Writer:  terminal.NewWriter(terminal.NewCustomStdio(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})),
 	}}
 }
 
diff --git a/ui/build/context.go b/ui/build/context.go
index 0636631..c8b00c3 100644
--- a/ui/build/context.go
+++ b/ui/build/context.go
@@ -16,45 +16,14 @@
 
 import (
 	"context"
-	"io"
-	"os"
-	"time"
 
 	"android/soong/ui/logger"
+	"android/soong/ui/status"
+	"android/soong/ui/terminal"
 	"android/soong/ui/tracer"
 )
 
-type StdioInterface interface {
-	Stdin() io.Reader
-	Stdout() io.Writer
-	Stderr() io.Writer
-}
-
-type StdioImpl struct{}
-
-func (StdioImpl) Stdin() io.Reader  { return os.Stdin }
-func (StdioImpl) Stdout() io.Writer { return os.Stdout }
-func (StdioImpl) Stderr() io.Writer { return os.Stderr }
-
-var _ StdioInterface = StdioImpl{}
-
-type customStdio struct {
-	stdin  io.Reader
-	stdout io.Writer
-	stderr io.Writer
-}
-
-func NewCustomStdio(stdin io.Reader, stdout, stderr io.Writer) StdioInterface {
-	return customStdio{stdin, stdout, stderr}
-}
-
-func (c customStdio) Stdin() io.Reader  { return c.stdin }
-func (c customStdio) Stdout() io.Writer { return c.stdout }
-func (c customStdio) Stderr() io.Writer { return c.stderr }
-
-var _ StdioInterface = customStdio{}
-
-// Context combines a context.Context, logger.Logger, and StdIO redirection.
+// Context combines a context.Context, logger.Logger, and terminal.Writer.
 // These all are agnostic of the current build, and may be used for multiple
 // builds, while the Config objects contain per-build information.
 type Context struct{ *ContextImpl }
@@ -62,7 +31,8 @@
 	context.Context
 	logger.Logger
 
-	StdioInterface
+	Writer terminal.Writer
+	Status *status.Status
 
 	Thread tracer.Thread
 	Tracer tracer.Tracer
@@ -88,28 +58,3 @@
 		c.Tracer.Complete(name, c.Thread, begin, end)
 	}
 }
-
-// ImportNinjaLog imports a .ninja_log file into the tracer.
-func (c ContextImpl) ImportNinjaLog(filename string, startOffset time.Time) {
-	if c.Tracer != nil {
-		c.Tracer.ImportNinjaLog(c.Thread, filename, startOffset)
-	}
-}
-
-func (c ContextImpl) IsTerminal() bool {
-	if term, ok := os.LookupEnv("TERM"); ok {
-		return term != "dumb" && isTerminal(c.Stdout()) && isTerminal(c.Stderr())
-	}
-	return false
-}
-
-func (c ContextImpl) IsErrTerminal() bool {
-	if term, ok := os.LookupEnv("TERM"); ok {
-		return term != "dumb" && isTerminal(c.Stderr())
-	}
-	return false
-}
-
-func (c ContextImpl) TermWidth() (int, bool) {
-	return termWidth(c.Stdout())
-}
diff --git a/ui/build/dumpvars.go b/ui/build/dumpvars.go
index 8429a8a..06bd74f 100644
--- a/ui/build/dumpvars.go
+++ b/ui/build/dumpvars.go
@@ -18,6 +18,8 @@
 	"bytes"
 	"fmt"
 	"strings"
+
+	"android/soong/ui/status"
 )
 
 // DumpMakeVars can be used to extract the values of Make variables after the
@@ -60,7 +62,7 @@
 	}
 	cmd.StartOrFatal()
 	// TODO: error out when Stderr contains any content
-	katiRewriteOutput(ctx, pipe)
+	status.KatiReader(ctx.Status.StartTool(), pipe)
 	cmd.WaitOrFatal()
 
 	ret := make(map[string]string, len(vars))
@@ -175,7 +177,7 @@
 	}
 
 	// Print the banner like make does
-	fmt.Fprintln(ctx.Stdout(), Banner(make_vars))
+	ctx.Writer.Print(Banner(make_vars))
 
 	// Populate the environment
 	env := config.Environment()
diff --git a/ui/build/exec.go b/ui/build/exec.go
index 90fb19d..5c312bc 100644
--- a/ui/build/exec.go
+++ b/ui/build/exec.go
@@ -122,3 +122,20 @@
 	c.reportError(err)
 	return ret
 }
+
+// RunAndPrintOrFatal will run the command, then after finishing
+// print any output, then handling any errors with a call to
+// ctx.Fatal
+func (c *Cmd) RunAndPrintOrFatal() {
+	ret, err := c.CombinedOutput()
+	st := c.ctx.Status.StartTool()
+	if len(ret) > 0 {
+		if err != nil {
+			st.Error(string(ret))
+		} else {
+			st.Print(string(ret))
+		}
+	}
+	st.Finish()
+	c.reportError(err)
+}
diff --git a/ui/build/kati.go b/ui/build/kati.go
index 7635c10..7cfa1cf 100644
--- a/ui/build/kati.go
+++ b/ui/build/kati.go
@@ -15,15 +15,14 @@
 package build
 
 import (
-	"bufio"
 	"crypto/md5"
 	"fmt"
-	"io"
 	"io/ioutil"
 	"path/filepath"
-	"regexp"
 	"strconv"
 	"strings"
+
+	"android/soong/ui/status"
 )
 
 var spaceSlashReplacer = strings.NewReplacer("/", "_", " ", "_")
@@ -117,77 +116,10 @@
 	cmd.Stderr = cmd.Stdout
 
 	cmd.StartOrFatal()
-	katiRewriteOutput(ctx, pipe)
+	status.KatiReader(ctx.Status.StartTool(), pipe)
 	cmd.WaitOrFatal()
 }
 
-var katiIncludeRe = regexp.MustCompile(`^(\[\d+/\d+] )?including [^ ]+ ...$`)
-var katiLogRe = regexp.MustCompile(`^\*kati\*: `)
-
-func katiRewriteOutput(ctx Context, pipe io.ReadCloser) {
-	haveBlankLine := true
-	smartTerminal := ctx.IsTerminal()
-	errSmartTerminal := ctx.IsErrTerminal()
-
-	scanner := bufio.NewScanner(pipe)
-	for scanner.Scan() {
-		line := scanner.Text()
-		verbose := katiIncludeRe.MatchString(line)
-
-		// Only put kati debug/stat lines in our verbose log
-		if katiLogRe.MatchString(line) {
-			ctx.Verbose(line)
-			continue
-		}
-
-		// For verbose lines, write them on the current line without a newline,
-		// then overwrite them if the next thing we're printing is another
-		// verbose line.
-		if smartTerminal && verbose {
-			// Limit line width to the terminal width, otherwise we'll wrap onto
-			// another line and we won't delete the previous line.
-			//
-			// Run this on every line in case the window has been resized while
-			// we're printing. This could be optimized to only re-run when we
-			// get SIGWINCH if it ever becomes too time consuming.
-			if max, ok := termWidth(ctx.Stdout()); ok {
-				if len(line) > max {
-					// Just do a max. Ninja elides the middle, but that's
-					// more complicated and these lines aren't that important.
-					line = line[:max]
-				}
-			}
-
-			// Move to the beginning on the line, print the output, then clear
-			// the rest of the line.
-			fmt.Fprint(ctx.Stdout(), "\r", line, "\x1b[K")
-			haveBlankLine = false
-			continue
-		} else if smartTerminal && !haveBlankLine {
-			// If we've previously written a verbose message, send a newline to save
-			// that message instead of overwriting it.
-			fmt.Fprintln(ctx.Stdout())
-			haveBlankLine = true
-		} else if !errSmartTerminal {
-			// Most editors display these as garbage, so strip them out.
-			line = string(stripAnsiEscapes([]byte(line)))
-		}
-
-		// Assume that non-verbose lines are important enough for stderr
-		fmt.Fprintln(ctx.Stderr(), line)
-	}
-
-	// Save our last verbose line.
-	if !haveBlankLine {
-		fmt.Fprintln(ctx.Stdout())
-	}
-
-	if err := scanner.Err(); err != nil {
-		ctx.Println("Error from kati parser:", err)
-		io.Copy(ctx.Stderr(), pipe)
-	}
-}
-
 func runKatiCleanSpec(ctx Context, config Config) {
 	ctx.BeginTrace("kati cleanspec")
 	defer ctx.EndTrace()
@@ -220,6 +152,6 @@
 	cmd.Stderr = cmd.Stdout
 
 	cmd.StartOrFatal()
-	katiRewriteOutput(ctx, pipe)
+	status.KatiReader(ctx.Status.StartTool(), pipe)
 	cmd.WaitOrFatal()
 }
diff --git a/ui/build/ninja.go b/ui/build/ninja.go
index 96b5e9d..c48fe0f 100644
--- a/ui/build/ninja.go
+++ b/ui/build/ninja.go
@@ -21,15 +21,21 @@
 	"strconv"
 	"strings"
 	"time"
+
+	"android/soong/ui/status"
 )
 
 func runNinja(ctx Context, config Config) {
 	ctx.BeginTrace("ninja")
 	defer ctx.EndTrace()
 
+	fifo := filepath.Join(config.OutDir(), ".ninja_fifo")
+	status.NinjaReader(ctx, ctx.Status.StartTool(), fifo)
+
 	executable := config.PrebuiltBuildTool("ninja")
 	args := []string{
 		"-d", "keepdepfile",
+		fmt.Sprintf("--frontend=cat <&3 >%s", fifo),
 	}
 
 	args = append(args, config.NinjaArgs()...)
@@ -47,9 +53,6 @@
 
 	args = append(args, "-f", config.CombinedNinjaFile())
 
-	if config.IsVerbose() {
-		args = append(args, "-v")
-	}
 	args = append(args, "-w", "dupbuild=err")
 
 	cmd := Command(ctx, config, "ninja", executable, args...)
@@ -66,13 +69,6 @@
 		cmd.Args = append(cmd.Args, strings.Fields(extra)...)
 	}
 
-	if _, ok := cmd.Environment.Get("NINJA_STATUS"); !ok {
-		cmd.Environment.Set("NINJA_STATUS", "[%p %f/%t] ")
-	}
-
-	cmd.Stdin = ctx.Stdin()
-	cmd.Stdout = ctx.Stdout()
-	cmd.Stderr = ctx.Stderr()
 	logPath := filepath.Join(config.OutDir(), ".ninja_log")
 	ninjaHeartbeatDuration := time.Minute * 5
 	if overrideText, ok := cmd.Environment.Get("NINJA_HEARTBEAT_INTERVAL"); ok {
@@ -99,10 +95,7 @@
 		}
 	}()
 
-	startTime := time.Now()
-	defer ctx.ImportNinjaLog(logPath, startTime)
-
-	cmd.RunOrFatal()
+	cmd.RunAndPrintOrFatal()
 }
 
 type statusChecker struct {
diff --git a/ui/build/soong.go b/ui/build/soong.go
index a73082a..6c94079 100644
--- a/ui/build/soong.go
+++ b/ui/build/soong.go
@@ -15,12 +15,15 @@
 package build
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"strconv"
-	"time"
+	"strings"
 
 	"github.com/google/blueprint/microfactory"
+
+	"android/soong/ui/status"
 )
 
 func runSoong(ctx Context, config Config) {
@@ -41,9 +44,8 @@
 		cmd.Environment.Set("SRCDIR", ".")
 		cmd.Environment.Set("TOPNAME", "Android.bp")
 		cmd.Sandbox = soongSandbox
-		cmd.Stdout = ctx.Stdout()
-		cmd.Stderr = ctx.Stderr()
-		cmd.RunOrFatal()
+
+		cmd.RunAndPrintOrFatal()
 	}()
 
 	func() {
@@ -56,12 +58,18 @@
 			if _, err := os.Stat(envTool); err == nil {
 				cmd := Command(ctx, config, "soong_env", envTool, envFile)
 				cmd.Sandbox = soongSandbox
-				cmd.Stdout = ctx.Stdout()
-				cmd.Stderr = ctx.Stderr()
+
+				var buf strings.Builder
+				cmd.Stdout = &buf
+				cmd.Stderr = &buf
 				if err := cmd.Run(); err != nil {
 					ctx.Verboseln("soong_env failed, forcing manifest regeneration")
 					os.Remove(envFile)
 				}
+
+				if buf.Len() > 0 {
+					ctx.Verboseln(buf.String())
+				}
 			} else {
 				ctx.Verboseln("Missing soong_env tool, forcing manifest regeneration")
 				os.Remove(envFile)
@@ -100,22 +108,18 @@
 		ctx.BeginTrace(name)
 		defer ctx.EndTrace()
 
+		fifo := filepath.Join(config.OutDir(), ".ninja_fifo")
+		status.NinjaReader(ctx, ctx.Status.StartTool(), fifo)
+
 		cmd := Command(ctx, config, "soong "+name,
 			config.PrebuiltBuildTool("ninja"),
 			"-d", "keepdepfile",
 			"-w", "dupbuild=err",
 			"-j", strconv.Itoa(config.Parallel()),
+			fmt.Sprintf("--frontend=cat <&3 >%s", fifo),
 			"-f", filepath.Join(config.SoongOutDir(), file))
-		if config.IsVerbose() {
-			cmd.Args = append(cmd.Args, "-v")
-		}
 		cmd.Sandbox = soongSandbox
-		cmd.Stdin = ctx.Stdin()
-		cmd.Stdout = ctx.Stdout()
-		cmd.Stderr = ctx.Stderr()
-
-		defer ctx.ImportNinjaLog(filepath.Join(config.OutDir(), ".ninja_log"), time.Now())
-		cmd.RunOrFatal()
+		cmd.RunAndPrintOrFatal()
 	}
 
 	ninja("minibootstrap", ".minibootstrap/build.ninja")
diff --git a/ui/build/util.go b/ui/build/util.go
index 96088fe..0676a86 100644
--- a/ui/build/util.go
+++ b/ui/build/util.go
@@ -15,13 +15,9 @@
 package build
 
 import (
-	"bytes"
-	"io"
 	"os"
 	"path/filepath"
 	"strings"
-	"syscall"
-	"unsafe"
 )
 
 func absPath(ctx Context, p string) string {
@@ -117,81 +113,3 @@
 	}
 	return str[:idx], str[idx+1:], true
 }
-
-func isTerminal(w io.Writer) bool {
-	if f, ok := w.(*os.File); ok {
-		var termios syscall.Termios
-		_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(),
-			ioctlGetTermios, uintptr(unsafe.Pointer(&termios)),
-			0, 0, 0)
-		return err == 0
-	}
-	return false
-}
-
-func termWidth(w io.Writer) (int, bool) {
-	if f, ok := w.(*os.File); ok {
-		var winsize struct {
-			ws_row, ws_column    uint16
-			ws_xpixel, ws_ypixel uint16
-		}
-		_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(),
-			syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&winsize)),
-			0, 0, 0)
-		return int(winsize.ws_column), err == 0
-	}
-	return 0, false
-}
-
-// stripAnsiEscapes strips ANSI control codes from a byte array in place.
-func stripAnsiEscapes(input []byte) []byte {
-	// read represents the remaining part of input that needs to be processed.
-	read := input
-	// write represents where we should be writing in input.
-	// It will share the same backing store as input so that we make our modifications
-	// in place.
-	write := input
-
-	// advance will copy count bytes from read to write and advance those slices
-	advance := func(write, read []byte, count int) ([]byte, []byte) {
-		copy(write, read[:count])
-		return write[count:], read[count:]
-	}
-
-	for {
-		// Find the next escape sequence
-		i := bytes.IndexByte(read, 0x1b)
-		// If it isn't found, or if there isn't room for <ESC>[, finish
-		if i == -1 || i+1 >= len(read) {
-			copy(write, read)
-			break
-		}
-
-		// Not a CSI code, continue searching
-		if read[i+1] != '[' {
-			write, read = advance(write, read, i+1)
-			continue
-		}
-
-		// Found a CSI code, advance up to the <ESC>
-		write, read = advance(write, read, i)
-
-		// Find the end of the CSI code
-		i = bytes.IndexFunc(read, func(r rune) bool {
-			return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
-		})
-		if i == -1 {
-			// We didn't find the end of the code, just remove the rest
-			i = len(read) - 1
-		}
-
-		// Strip off the end marker too
-		i = i + 1
-
-		// Skip the reader forward and reduce final length by that amount
-		read = read[i:]
-		input = input[:len(input)-i]
-	}
-
-	return input
-}
diff --git a/ui/build/util_test.go b/ui/build/util_test.go
index 0e0dbdf..89bfc77 100644
--- a/ui/build/util_test.go
+++ b/ui/build/util_test.go
@@ -49,48 +49,3 @@
 
 	ensureEmptyDirectoriesExist(ctx, filepath.Join(tmpDir, "a"))
 }
-
-func TestStripAnsiEscapes(t *testing.T) {
-	testcases := []struct {
-		input  string
-		output string
-	}{
-		{
-			"",
-			"",
-		},
-		{
-			"This is a test",
-			"This is a test",
-		},
-		{
-			"interrupted: \x1b[12",
-			"interrupted: ",
-		},
-		{
-			"other \x1bescape \x1b",
-			"other \x1bescape \x1b",
-		},
-		{ // from pretty-error macro
-			"\x1b[1mart/Android.mk: \x1b[31merror:\x1b[0m\x1b[1m art: test error \x1b[0m",
-			"art/Android.mk: error: art: test error ",
-		},
-		{ // from envsetup.sh make wrapper
-			"\x1b[0;31m#### make failed to build some targets (2 seconds) ####\x1b[00m",
-			"#### make failed to build some targets (2 seconds) ####",
-		},
-		{ // from clang (via ninja testcase)
-			"\x1b[1maffixmgr.cxx:286:15: \x1b[0m\x1b[0;1;35mwarning: \x1b[0m\x1b[1musing the result... [-Wparentheses]\x1b[0m",
-			"affixmgr.cxx:286:15: warning: using the result... [-Wparentheses]",
-		},
-	}
-	for _, tc := range testcases {
-		got := string(stripAnsiEscapes([]byte(tc.input)))
-		if got != tc.output {
-			t.Errorf("output strings didn't match\n"+
-				"input: %#v\n"+
-				" want: %#v\n"+
-				"  got: %#v", tc.input, tc.output, got)
-		}
-	}
-}
diff --git a/ui/status/Android.bp b/ui/status/Android.bp
new file mode 100644
index 0000000..76caaef
--- /dev/null
+++ b/ui/status/Android.bp
@@ -0,0 +1,42 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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.
+
+bootstrap_go_package {
+    name: "soong-ui-status",
+    pkgPath: "android/soong/ui/status",
+    deps: [
+        "golang-protobuf-proto",
+        "soong-ui-logger",
+        "soong-ui-status-ninja_frontend",
+    ],
+    srcs: [
+        "kati.go",
+        "log.go",
+        "ninja.go",
+        "status.go",
+    ],
+    testSrcs: [
+        "kati_test.go",
+        "status_test.go",
+    ],
+}
+
+bootstrap_go_package {
+    name: "soong-ui-status-ninja_frontend",
+    pkgPath: "android/soong/ui/status/ninja_frontend",
+    deps: ["golang-protobuf-proto"],
+    srcs: [
+        "ninja_frontend/frontend.pb.go",
+    ],
+}
diff --git a/ui/status/kati.go b/ui/status/kati.go
new file mode 100644
index 0000000..552a9e9
--- /dev/null
+++ b/ui/status/kati.go
@@ -0,0 +1,138 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+var katiError = regexp.MustCompile(`^(\033\[1m)?[^ ]+:[0-9]+: (\033\[31m)?error:`)
+var katiIncludeRe = regexp.MustCompile(`^(\[(\d+)/(\d+)] )?((including [^ ]+|initializing build system|finishing build rules|writing build rules) ...)$`)
+var katiLogRe = regexp.MustCompile(`^\*kati\*: `)
+var katiNinjaMissing = regexp.MustCompile("^[^ ]+ is missing, regenerating...$")
+
+type katiOutputParser struct {
+	st ToolStatus
+
+	count int
+	total int
+	extra int
+
+	action   *Action
+	buf      strings.Builder
+	hasError bool
+}
+
+func (k *katiOutputParser) flushAction() {
+	if k.action == nil {
+		return
+	}
+
+	var err error
+	if k.hasError {
+		err = fmt.Errorf("makefile error")
+	}
+
+	k.st.FinishAction(ActionResult{
+		Action: k.action,
+		Output: k.buf.String(),
+		Error:  err,
+	})
+
+	k.buf.Reset()
+	k.hasError = false
+}
+
+func (k *katiOutputParser) parseLine(line string) {
+	// Only put kati debug/stat lines in our verbose log
+	if katiLogRe.MatchString(line) {
+		k.st.Verbose(line)
+		return
+	}
+
+	if matches := katiIncludeRe.FindStringSubmatch(line); len(matches) > 0 {
+		k.flushAction()
+		k.count += 1
+
+		matches := katiIncludeRe.FindStringSubmatch(line)
+		if matches[2] != "" {
+			idx, err := strconv.Atoi(matches[2])
+
+			if err == nil && idx+k.extra != k.count {
+				k.extra = k.count - idx
+				k.st.SetTotalActions(k.total + k.extra)
+			}
+		} else {
+			k.extra += 1
+			k.st.SetTotalActions(k.total + k.extra)
+		}
+
+		if matches[3] != "" {
+			tot, err := strconv.Atoi(matches[3])
+
+			if err == nil && tot != k.total {
+				k.total = tot
+				k.st.SetTotalActions(k.total + k.extra)
+			}
+		}
+
+		k.action = &Action{
+			Description: matches[4],
+		}
+		k.st.StartAction(k.action)
+	} else if k.action != nil {
+		if katiError.MatchString(line) {
+			k.hasError = true
+		}
+		k.buf.WriteString(line)
+		k.buf.WriteString("\n")
+	} else {
+		// Before we've started executing actions from Kati
+		if line == "No need to regenerate ninja file" || katiNinjaMissing.MatchString(line) {
+			k.st.Status(line)
+		} else {
+			k.st.Print(line)
+		}
+	}
+}
+
+// KatiReader reads the output from Kati, and turns it into Actions and
+// messages that are passed into the ToolStatus API.
+func KatiReader(st ToolStatus, pipe io.ReadCloser) {
+	parser := &katiOutputParser{
+		st: st,
+	}
+
+	scanner := bufio.NewScanner(pipe)
+	for scanner.Scan() {
+		parser.parseLine(scanner.Text())
+	}
+
+	parser.flushAction()
+
+	if err := scanner.Err(); err != nil {
+		var buf strings.Builder
+		io.Copy(&buf, pipe)
+		st.Print(fmt.Sprintf("Error from kati parser: %s", err))
+		st.Print(buf.String())
+	}
+
+	st.Finish()
+}
diff --git a/ui/status/kati_test.go b/ui/status/kati_test.go
new file mode 100644
index 0000000..f2cb813
--- /dev/null
+++ b/ui/status/kati_test.go
@@ -0,0 +1,175 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status
+
+import (
+	"testing"
+)
+
+type lastOutput struct {
+	counterOutput
+
+	action *Action
+	result ActionResult
+
+	msgLevel MsgLevel
+	msg      string
+}
+
+func (l *lastOutput) StartAction(a *Action, c Counts) {
+	l.action = a
+	l.counterOutput.StartAction(a, c)
+}
+func (l *lastOutput) FinishAction(r ActionResult, c Counts) {
+	l.result = r
+	l.counterOutput.FinishAction(r, c)
+}
+func (l *lastOutput) Message(level MsgLevel, msg string) {
+	l.msgLevel = level
+	l.msg = msg
+}
+func (l *lastOutput) Flush() {}
+
+func TestKatiNormalCase(t *testing.T) {
+	status := &Status{}
+	output := &lastOutput{}
+	status.AddOutput(output)
+
+	parser := &katiOutputParser{
+		st: status.StartTool(),
+	}
+
+	msg := "*kati*: verbose msg"
+	parser.parseLine(msg)
+	output.Expect(t, Counts{})
+
+	if output.msgLevel != VerboseLvl {
+		t.Errorf("Expected verbose message, but got %d", output.msgLevel)
+	}
+	if output.msg != msg {
+		t.Errorf("unexpected message contents:\nwant: %q\n got: %q\n", msg, output.msg)
+	}
+
+	parser.parseLine("out/build-aosp_arm.ninja is missing, regenerating...")
+	output.Expect(t, Counts{})
+
+	parser.parseLine("[1/1] initializing build system ...")
+	output.Expect(t, Counts{
+		TotalActions:    1,
+		RunningActions:  1,
+		StartedActions:  1,
+		FinishedActions: 0,
+	})
+
+	parser.parseLine("[2/5] including out/soong/Android-aosp_arm.mk ...")
+	output.Expect(t, Counts{
+		TotalActions:    5,
+		RunningActions:  1,
+		StartedActions:  2,
+		FinishedActions: 1,
+	})
+
+	parser.parseLine("[3/5] including a ...")
+	msg = "a random message"
+	parser.parseLine(msg)
+
+	// Start the next line to flush the previous result
+	parser.parseLine("[4/5] finishing build rules ...")
+
+	msg += "\n"
+	if output.result.Output != msg {
+		t.Errorf("output for action did not match:\nwant: %q\n got: %q\n", msg, output.result.Output)
+	}
+
+	parser.parseLine("[5/5] writing build rules ...")
+	parser.parseLine("*kati*: verbose msg")
+	parser.flushAction()
+
+	if output.result.Output != "" {
+		t.Errorf("expected no output for last action, but got %q", output.result.Output)
+	}
+
+	output.Expect(t, Counts{
+		TotalActions:    5,
+		RunningActions:  0,
+		StartedActions:  5,
+		FinishedActions: 5,
+	})
+}
+
+func TestKatiExtraIncludes(t *testing.T) {
+	status := &Status{}
+	output := &lastOutput{}
+	status.AddOutput(output)
+
+	parser := &katiOutputParser{
+		st: status.StartTool(),
+	}
+
+	parser.parseLine("[1/1] initializing build system ...")
+	parser.parseLine("[2/5] including out/soong/Android-aosp_arm.mk ...")
+	output.Expect(t, Counts{
+		TotalActions:    5,
+		RunningActions:  1,
+		StartedActions:  2,
+		FinishedActions: 1,
+	})
+
+	parser.parseLine("including a ...")
+
+	output.Expect(t, Counts{
+		TotalActions:    6,
+		RunningActions:  1,
+		StartedActions:  3,
+		FinishedActions: 2,
+	})
+
+	parser.parseLine("including b ...")
+
+	output.Expect(t, Counts{
+		TotalActions:    7,
+		RunningActions:  1,
+		StartedActions:  4,
+		FinishedActions: 3,
+	})
+
+	parser.parseLine("[3/5] finishing build rules ...")
+
+	output.Expect(t, Counts{
+		TotalActions:    7,
+		RunningActions:  1,
+		StartedActions:  5,
+		FinishedActions: 4,
+	})
+}
+
+func TestKatiFailOnError(t *testing.T) {
+	status := &Status{}
+	output := &lastOutput{}
+	status.AddOutput(output)
+
+	parser := &katiOutputParser{
+		st: status.StartTool(),
+	}
+
+	parser.parseLine("[1/1] initializing build system ...")
+	parser.parseLine("[2/5] inclduing out/soong/Android-aosp_arm.mk ...")
+	parser.parseLine("build/make/tools/Android.mk:19: error: testing")
+	parser.flushAction()
+
+	if output.result.Error == nil {
+		t.Errorf("Expected the last action to be marked as an error")
+	}
+}
diff --git a/ui/status/log.go b/ui/status/log.go
new file mode 100644
index 0000000..921aa44
--- /dev/null
+++ b/ui/status/log.go
@@ -0,0 +1,136 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status
+
+import (
+	"android/soong/ui/logger"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"strings"
+)
+
+type verboseLog struct {
+	w io.WriteCloser
+}
+
+func NewVerboseLog(log logger.Logger, filename string) StatusOutput {
+	if !strings.HasSuffix(filename, ".gz") {
+		filename += ".gz"
+	}
+
+	f, err := logger.CreateFileWithRotation(filename, 5)
+	if err != nil {
+		log.Println("Failed to create verbose log file:", err)
+		return nil
+	}
+
+	w := gzip.NewWriter(f)
+
+	return &verboseLog{
+		w: w,
+	}
+}
+
+func (v *verboseLog) StartAction(action *Action, counts Counts) {}
+
+func (v *verboseLog) FinishAction(result ActionResult, counts Counts) {
+	cmd := result.Command
+	if cmd == "" {
+		cmd = result.Description
+	}
+
+	fmt.Fprintf(v.w, "[%d/%d] %s\n", counts.FinishedActions, counts.TotalActions, cmd)
+
+	if result.Error != nil {
+		fmt.Fprintf(v.w, "FAILED: %s\n", strings.Join(result.Outputs, " "))
+	}
+
+	if result.Output != "" {
+		fmt.Fprintln(v.w, result.Output)
+	}
+}
+
+func (v *verboseLog) Flush() {
+	v.w.Close()
+}
+
+func (v *verboseLog) Message(level MsgLevel, message string) {
+	fmt.Fprintf(v.w, "%s%s\n", level.Prefix(), message)
+}
+
+type errorLog struct {
+	w io.WriteCloser
+
+	empty bool
+}
+
+func NewErrorLog(log logger.Logger, filename string) StatusOutput {
+	f, err := logger.CreateFileWithRotation(filename, 5)
+	if err != nil {
+		log.Println("Failed to create error log file:", err)
+		return nil
+	}
+
+	return &errorLog{
+		w:     f,
+		empty: true,
+	}
+}
+
+func (e *errorLog) StartAction(action *Action, counts Counts) {}
+
+func (e *errorLog) FinishAction(result ActionResult, counts Counts) {
+	if result.Error == nil {
+		return
+	}
+
+	cmd := result.Command
+	if cmd == "" {
+		cmd = result.Description
+	}
+
+	if !e.empty {
+		fmt.Fprintf(e.w, "\n\n")
+	}
+	e.empty = false
+
+	fmt.Fprintf(e.w, "FAILED: %s\n", result.Description)
+	if len(result.Outputs) > 0 {
+		fmt.Fprintf(e.w, "Outputs: %s\n", strings.Join(result.Outputs, " "))
+	}
+	fmt.Fprintf(e.w, "Error: %s\n", result.Error)
+	if result.Command != "" {
+		fmt.Fprintf(e.w, "Command: %s\n", result.Command)
+	}
+	fmt.Fprintf(e.w, "Output:\n%s\n", result.Output)
+}
+
+func (e *errorLog) Flush() {
+	e.w.Close()
+}
+
+func (e *errorLog) Message(level MsgLevel, message string) {
+	if level < ErrorLvl {
+		return
+	}
+
+	if !e.empty {
+		fmt.Fprintf(e.w, "\n\n")
+	}
+	e.empty = false
+
+	fmt.Fprintf(e.w, "error: %s\n", message)
+}
diff --git a/ui/status/ninja.go b/ui/status/ninja.go
new file mode 100644
index 0000000..7d330f9
--- /dev/null
+++ b/ui/status/ninja.go
@@ -0,0 +1,151 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"syscall"
+
+	"github.com/golang/protobuf/proto"
+
+	"android/soong/ui/logger"
+	"android/soong/ui/status/ninja_frontend"
+)
+
+// NinjaReader reads the protobuf frontend format from ninja and translates it
+// into calls on the ToolStatus API.
+func NinjaReader(ctx logger.Logger, status ToolStatus, fifo string) {
+	os.Remove(fifo)
+
+	err := syscall.Mkfifo(fifo, 0666)
+	if err != nil {
+		ctx.Fatalf("Failed to mkfifo(%q): %v", fifo, err)
+	}
+
+	go ninjaReader(status, fifo)
+}
+
+func ninjaReader(status ToolStatus, fifo string) {
+	f, err := os.Open(fifo)
+	if err != nil {
+		status.Error(fmt.Sprintf("Failed to open fifo:", err))
+	}
+	defer f.Close()
+
+	r := bufio.NewReader(f)
+
+	running := map[uint32]*Action{}
+
+	for {
+		size, err := readVarInt(r)
+		if err != nil {
+			if err != io.EOF {
+				status.Error(fmt.Sprintf("Got error reading from ninja: %s", err))
+			}
+			return
+		}
+
+		buf := make([]byte, size)
+		_, err = io.ReadFull(r, buf)
+		if err != nil {
+			if err == io.EOF {
+				status.Print(fmt.Sprintf("Missing message of size %d from ninja\n", size))
+			} else {
+				status.Error(fmt.Sprintf("Got error reading from ninja: %s", err))
+			}
+			return
+		}
+
+		msg := &ninja_frontend.Status{}
+		err = proto.Unmarshal(buf, msg)
+		if err != nil {
+			status.Print(fmt.Sprintf("Error reading message from ninja: %v", err))
+			continue
+		}
+
+		// Ignore msg.BuildStarted
+		if msg.TotalEdges != nil {
+			status.SetTotalActions(int(msg.TotalEdges.GetTotalEdges()))
+		}
+		if msg.EdgeStarted != nil {
+			action := &Action{
+				Description: msg.EdgeStarted.GetDesc(),
+				Outputs:     msg.EdgeStarted.Outputs,
+				Command:     msg.EdgeStarted.GetCommand(),
+			}
+			status.StartAction(action)
+			running[msg.EdgeStarted.GetId()] = action
+		}
+		if msg.EdgeFinished != nil {
+			if started, ok := running[msg.EdgeFinished.GetId()]; ok {
+				delete(running, msg.EdgeFinished.GetId())
+
+				var err error
+				exitCode := int(msg.EdgeFinished.GetStatus())
+				if exitCode != 0 {
+					err = fmt.Errorf("exited with code: %d", exitCode)
+				}
+
+				status.FinishAction(ActionResult{
+					Action: started,
+					Output: msg.EdgeFinished.GetOutput(),
+					Error:  err,
+				})
+			}
+		}
+		if msg.Message != nil {
+			message := "ninja: " + msg.Message.GetMessage()
+			switch msg.Message.GetLevel() {
+			case ninja_frontend.Status_Message_INFO:
+				status.Status(message)
+			case ninja_frontend.Status_Message_WARNING:
+				status.Print("warning: " + message)
+			case ninja_frontend.Status_Message_ERROR:
+				status.Error(message)
+			default:
+				status.Print(message)
+			}
+		}
+		if msg.BuildFinished != nil {
+			status.Finish()
+		}
+	}
+}
+
+func readVarInt(r *bufio.Reader) (int, error) {
+	ret := 0
+	shift := uint(0)
+
+	for {
+		b, err := r.ReadByte()
+		if err != nil {
+			return 0, err
+		}
+
+		ret += int(b&0x7f) << (shift * 7)
+		if b&0x80 == 0 {
+			break
+		}
+		shift += 1
+		if shift > 4 {
+			return 0, fmt.Errorf("Expected varint32 length-delimited message")
+		}
+	}
+
+	return ret, nil
+}
diff --git a/ui/status/ninja_frontend/README b/ui/status/ninja_frontend/README
new file mode 100644
index 0000000..8c4b451
--- /dev/null
+++ b/ui/status/ninja_frontend/README
@@ -0,0 +1,3 @@
+This comes from https://android.googlesource.com/platform/external/ninja/+/master/src/frontend.proto
+
+The only difference is the specification of a go_package. To regenerate frontend.pb.go, run regen.sh.
diff --git a/ui/status/ninja_frontend/frontend.pb.go b/ui/status/ninja_frontend/frontend.pb.go
new file mode 100644
index 0000000..7c05eed
--- /dev/null
+++ b/ui/status/ninja_frontend/frontend.pb.go
@@ -0,0 +1,510 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// source: frontend.proto
+
+package ninja_frontend
+
+import proto "github.com/golang/protobuf/proto"
+import fmt "fmt"
+import math "math"
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
+
+type Status_Message_Level int32
+
+const (
+	Status_Message_INFO    Status_Message_Level = 0
+	Status_Message_WARNING Status_Message_Level = 1
+	Status_Message_ERROR   Status_Message_Level = 2
+)
+
+var Status_Message_Level_name = map[int32]string{
+	0: "INFO",
+	1: "WARNING",
+	2: "ERROR",
+}
+var Status_Message_Level_value = map[string]int32{
+	"INFO":    0,
+	"WARNING": 1,
+	"ERROR":   2,
+}
+
+func (x Status_Message_Level) Enum() *Status_Message_Level {
+	p := new(Status_Message_Level)
+	*p = x
+	return p
+}
+func (x Status_Message_Level) String() string {
+	return proto.EnumName(Status_Message_Level_name, int32(x))
+}
+func (x *Status_Message_Level) UnmarshalJSON(data []byte) error {
+	value, err := proto.UnmarshalJSONEnum(Status_Message_Level_value, data, "Status_Message_Level")
+	if err != nil {
+		return err
+	}
+	*x = Status_Message_Level(value)
+	return nil
+}
+func (Status_Message_Level) EnumDescriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 5, 0}
+}
+
+type Status struct {
+	TotalEdges           *Status_TotalEdges    `protobuf:"bytes,1,opt,name=total_edges,json=totalEdges" json:"total_edges,omitempty"`
+	BuildStarted         *Status_BuildStarted  `protobuf:"bytes,2,opt,name=build_started,json=buildStarted" json:"build_started,omitempty"`
+	BuildFinished        *Status_BuildFinished `protobuf:"bytes,3,opt,name=build_finished,json=buildFinished" json:"build_finished,omitempty"`
+	EdgeStarted          *Status_EdgeStarted   `protobuf:"bytes,4,opt,name=edge_started,json=edgeStarted" json:"edge_started,omitempty"`
+	EdgeFinished         *Status_EdgeFinished  `protobuf:"bytes,5,opt,name=edge_finished,json=edgeFinished" json:"edge_finished,omitempty"`
+	Message              *Status_Message       `protobuf:"bytes,6,opt,name=message" json:"message,omitempty"`
+	XXX_NoUnkeyedLiteral struct{}              `json:"-"`
+	XXX_unrecognized     []byte                `json:"-"`
+	XXX_sizecache        int32                 `json:"-"`
+}
+
+func (m *Status) Reset()         { *m = Status{} }
+func (m *Status) String() string { return proto.CompactTextString(m) }
+func (*Status) ProtoMessage()    {}
+func (*Status) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0}
+}
+func (m *Status) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status.Unmarshal(m, b)
+}
+func (m *Status) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status.Marshal(b, m, deterministic)
+}
+func (dst *Status) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status.Merge(dst, src)
+}
+func (m *Status) XXX_Size() int {
+	return xxx_messageInfo_Status.Size(m)
+}
+func (m *Status) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status proto.InternalMessageInfo
+
+func (m *Status) GetTotalEdges() *Status_TotalEdges {
+	if m != nil {
+		return m.TotalEdges
+	}
+	return nil
+}
+
+func (m *Status) GetBuildStarted() *Status_BuildStarted {
+	if m != nil {
+		return m.BuildStarted
+	}
+	return nil
+}
+
+func (m *Status) GetBuildFinished() *Status_BuildFinished {
+	if m != nil {
+		return m.BuildFinished
+	}
+	return nil
+}
+
+func (m *Status) GetEdgeStarted() *Status_EdgeStarted {
+	if m != nil {
+		return m.EdgeStarted
+	}
+	return nil
+}
+
+func (m *Status) GetEdgeFinished() *Status_EdgeFinished {
+	if m != nil {
+		return m.EdgeFinished
+	}
+	return nil
+}
+
+func (m *Status) GetMessage() *Status_Message {
+	if m != nil {
+		return m.Message
+	}
+	return nil
+}
+
+type Status_TotalEdges struct {
+	// New value for total edges in the build.
+	TotalEdges           *uint32  `protobuf:"varint,1,opt,name=total_edges,json=totalEdges" json:"total_edges,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_TotalEdges) Reset()         { *m = Status_TotalEdges{} }
+func (m *Status_TotalEdges) String() string { return proto.CompactTextString(m) }
+func (*Status_TotalEdges) ProtoMessage()    {}
+func (*Status_TotalEdges) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 0}
+}
+func (m *Status_TotalEdges) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_TotalEdges.Unmarshal(m, b)
+}
+func (m *Status_TotalEdges) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_TotalEdges.Marshal(b, m, deterministic)
+}
+func (dst *Status_TotalEdges) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_TotalEdges.Merge(dst, src)
+}
+func (m *Status_TotalEdges) XXX_Size() int {
+	return xxx_messageInfo_Status_TotalEdges.Size(m)
+}
+func (m *Status_TotalEdges) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_TotalEdges.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_TotalEdges proto.InternalMessageInfo
+
+func (m *Status_TotalEdges) GetTotalEdges() uint32 {
+	if m != nil && m.TotalEdges != nil {
+		return *m.TotalEdges
+	}
+	return 0
+}
+
+type Status_BuildStarted struct {
+	// Number of jobs Ninja will run in parallel.
+	Parallelism *uint32 `protobuf:"varint,1,opt,name=parallelism" json:"parallelism,omitempty"`
+	// Verbose value passed to ninja.
+	Verbose              *bool    `protobuf:"varint,2,opt,name=verbose" json:"verbose,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_BuildStarted) Reset()         { *m = Status_BuildStarted{} }
+func (m *Status_BuildStarted) String() string { return proto.CompactTextString(m) }
+func (*Status_BuildStarted) ProtoMessage()    {}
+func (*Status_BuildStarted) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 1}
+}
+func (m *Status_BuildStarted) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_BuildStarted.Unmarshal(m, b)
+}
+func (m *Status_BuildStarted) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_BuildStarted.Marshal(b, m, deterministic)
+}
+func (dst *Status_BuildStarted) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_BuildStarted.Merge(dst, src)
+}
+func (m *Status_BuildStarted) XXX_Size() int {
+	return xxx_messageInfo_Status_BuildStarted.Size(m)
+}
+func (m *Status_BuildStarted) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_BuildStarted.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_BuildStarted proto.InternalMessageInfo
+
+func (m *Status_BuildStarted) GetParallelism() uint32 {
+	if m != nil && m.Parallelism != nil {
+		return *m.Parallelism
+	}
+	return 0
+}
+
+func (m *Status_BuildStarted) GetVerbose() bool {
+	if m != nil && m.Verbose != nil {
+		return *m.Verbose
+	}
+	return false
+}
+
+type Status_BuildFinished struct {
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_BuildFinished) Reset()         { *m = Status_BuildFinished{} }
+func (m *Status_BuildFinished) String() string { return proto.CompactTextString(m) }
+func (*Status_BuildFinished) ProtoMessage()    {}
+func (*Status_BuildFinished) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 2}
+}
+func (m *Status_BuildFinished) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_BuildFinished.Unmarshal(m, b)
+}
+func (m *Status_BuildFinished) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_BuildFinished.Marshal(b, m, deterministic)
+}
+func (dst *Status_BuildFinished) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_BuildFinished.Merge(dst, src)
+}
+func (m *Status_BuildFinished) XXX_Size() int {
+	return xxx_messageInfo_Status_BuildFinished.Size(m)
+}
+func (m *Status_BuildFinished) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_BuildFinished.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_BuildFinished proto.InternalMessageInfo
+
+type Status_EdgeStarted struct {
+	// Edge identification number, unique to a Ninja run.
+	Id *uint32 `protobuf:"varint,1,opt,name=id" json:"id,omitempty"`
+	// Edge start time in milliseconds since Ninja started.
+	StartTime *uint32 `protobuf:"varint,2,opt,name=start_time,json=startTime" json:"start_time,omitempty"`
+	// List of edge inputs.
+	Inputs []string `protobuf:"bytes,3,rep,name=inputs" json:"inputs,omitempty"`
+	// List of edge outputs.
+	Outputs []string `protobuf:"bytes,4,rep,name=outputs" json:"outputs,omitempty"`
+	// Description field from the edge.
+	Desc *string `protobuf:"bytes,5,opt,name=desc" json:"desc,omitempty"`
+	// Command field from the edge.
+	Command *string `protobuf:"bytes,6,opt,name=command" json:"command,omitempty"`
+	// Edge uses console.
+	Console              *bool    `protobuf:"varint,7,opt,name=console" json:"console,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_EdgeStarted) Reset()         { *m = Status_EdgeStarted{} }
+func (m *Status_EdgeStarted) String() string { return proto.CompactTextString(m) }
+func (*Status_EdgeStarted) ProtoMessage()    {}
+func (*Status_EdgeStarted) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 3}
+}
+func (m *Status_EdgeStarted) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_EdgeStarted.Unmarshal(m, b)
+}
+func (m *Status_EdgeStarted) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_EdgeStarted.Marshal(b, m, deterministic)
+}
+func (dst *Status_EdgeStarted) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_EdgeStarted.Merge(dst, src)
+}
+func (m *Status_EdgeStarted) XXX_Size() int {
+	return xxx_messageInfo_Status_EdgeStarted.Size(m)
+}
+func (m *Status_EdgeStarted) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_EdgeStarted.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_EdgeStarted proto.InternalMessageInfo
+
+func (m *Status_EdgeStarted) GetId() uint32 {
+	if m != nil && m.Id != nil {
+		return *m.Id
+	}
+	return 0
+}
+
+func (m *Status_EdgeStarted) GetStartTime() uint32 {
+	if m != nil && m.StartTime != nil {
+		return *m.StartTime
+	}
+	return 0
+}
+
+func (m *Status_EdgeStarted) GetInputs() []string {
+	if m != nil {
+		return m.Inputs
+	}
+	return nil
+}
+
+func (m *Status_EdgeStarted) GetOutputs() []string {
+	if m != nil {
+		return m.Outputs
+	}
+	return nil
+}
+
+func (m *Status_EdgeStarted) GetDesc() string {
+	if m != nil && m.Desc != nil {
+		return *m.Desc
+	}
+	return ""
+}
+
+func (m *Status_EdgeStarted) GetCommand() string {
+	if m != nil && m.Command != nil {
+		return *m.Command
+	}
+	return ""
+}
+
+func (m *Status_EdgeStarted) GetConsole() bool {
+	if m != nil && m.Console != nil {
+		return *m.Console
+	}
+	return false
+}
+
+type Status_EdgeFinished struct {
+	// Edge identification number, unique to a Ninja run.
+	Id *uint32 `protobuf:"varint,1,opt,name=id" json:"id,omitempty"`
+	// Edge end time in milliseconds since Ninja started.
+	EndTime *uint32 `protobuf:"varint,2,opt,name=end_time,json=endTime" json:"end_time,omitempty"`
+	// Exit status (0 for success).
+	Status *int32 `protobuf:"zigzag32,3,opt,name=status" json:"status,omitempty"`
+	// Edge output, may contain ANSI codes.
+	Output               *string  `protobuf:"bytes,4,opt,name=output" json:"output,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_EdgeFinished) Reset()         { *m = Status_EdgeFinished{} }
+func (m *Status_EdgeFinished) String() string { return proto.CompactTextString(m) }
+func (*Status_EdgeFinished) ProtoMessage()    {}
+func (*Status_EdgeFinished) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 4}
+}
+func (m *Status_EdgeFinished) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_EdgeFinished.Unmarshal(m, b)
+}
+func (m *Status_EdgeFinished) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_EdgeFinished.Marshal(b, m, deterministic)
+}
+func (dst *Status_EdgeFinished) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_EdgeFinished.Merge(dst, src)
+}
+func (m *Status_EdgeFinished) XXX_Size() int {
+	return xxx_messageInfo_Status_EdgeFinished.Size(m)
+}
+func (m *Status_EdgeFinished) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_EdgeFinished.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_EdgeFinished proto.InternalMessageInfo
+
+func (m *Status_EdgeFinished) GetId() uint32 {
+	if m != nil && m.Id != nil {
+		return *m.Id
+	}
+	return 0
+}
+
+func (m *Status_EdgeFinished) GetEndTime() uint32 {
+	if m != nil && m.EndTime != nil {
+		return *m.EndTime
+	}
+	return 0
+}
+
+func (m *Status_EdgeFinished) GetStatus() int32 {
+	if m != nil && m.Status != nil {
+		return *m.Status
+	}
+	return 0
+}
+
+func (m *Status_EdgeFinished) GetOutput() string {
+	if m != nil && m.Output != nil {
+		return *m.Output
+	}
+	return ""
+}
+
+type Status_Message struct {
+	// Message priority level (INFO, WARNING, or ERROR).
+	Level *Status_Message_Level `protobuf:"varint,1,opt,name=level,enum=ninja.Status_Message_Level,def=0" json:"level,omitempty"`
+	// Info/warning/error message from Ninja.
+	Message              *string  `protobuf:"bytes,2,opt,name=message" json:"message,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *Status_Message) Reset()         { *m = Status_Message{} }
+func (m *Status_Message) String() string { return proto.CompactTextString(m) }
+func (*Status_Message) ProtoMessage()    {}
+func (*Status_Message) Descriptor() ([]byte, []int) {
+	return fileDescriptor_frontend_5a49d9b15a642005, []int{0, 5}
+}
+func (m *Status_Message) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_Status_Message.Unmarshal(m, b)
+}
+func (m *Status_Message) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_Status_Message.Marshal(b, m, deterministic)
+}
+func (dst *Status_Message) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_Status_Message.Merge(dst, src)
+}
+func (m *Status_Message) XXX_Size() int {
+	return xxx_messageInfo_Status_Message.Size(m)
+}
+func (m *Status_Message) XXX_DiscardUnknown() {
+	xxx_messageInfo_Status_Message.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_Status_Message proto.InternalMessageInfo
+
+const Default_Status_Message_Level Status_Message_Level = Status_Message_INFO
+
+func (m *Status_Message) GetLevel() Status_Message_Level {
+	if m != nil && m.Level != nil {
+		return *m.Level
+	}
+	return Default_Status_Message_Level
+}
+
+func (m *Status_Message) GetMessage() string {
+	if m != nil && m.Message != nil {
+		return *m.Message
+	}
+	return ""
+}
+
+func init() {
+	proto.RegisterType((*Status)(nil), "ninja.Status")
+	proto.RegisterType((*Status_TotalEdges)(nil), "ninja.Status.TotalEdges")
+	proto.RegisterType((*Status_BuildStarted)(nil), "ninja.Status.BuildStarted")
+	proto.RegisterType((*Status_BuildFinished)(nil), "ninja.Status.BuildFinished")
+	proto.RegisterType((*Status_EdgeStarted)(nil), "ninja.Status.EdgeStarted")
+	proto.RegisterType((*Status_EdgeFinished)(nil), "ninja.Status.EdgeFinished")
+	proto.RegisterType((*Status_Message)(nil), "ninja.Status.Message")
+	proto.RegisterEnum("ninja.Status_Message_Level", Status_Message_Level_name, Status_Message_Level_value)
+}
+
+func init() { proto.RegisterFile("frontend.proto", fileDescriptor_frontend_5a49d9b15a642005) }
+
+var fileDescriptor_frontend_5a49d9b15a642005 = []byte{
+	// 496 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x53, 0xd1, 0x6e, 0xd3, 0x30,
+	0x14, 0xa5, 0x69, 0xd3, 0x34, 0x37, 0x6d, 0x28, 0x96, 0x40, 0x59, 0x10, 0xa2, 0xda, 0xd3, 0x78,
+	0x20, 0x48, 0xbc, 0x20, 0x10, 0x12, 0xa2, 0xd2, 0x06, 0x43, 0xd0, 0x49, 0xde, 0x24, 0x24, 0x5e,
+	0xaa, 0x74, 0xf6, 0x86, 0x51, 0xe2, 0x54, 0xb1, 0xbb, 0x5f, 0xe0, 0x7f, 0x78, 0xe0, 0xfb, 0x90,
+	0xaf, 0xed, 0x2c, 0x65, 0x7b, 0xcb, 0xf1, 0x3d, 0xe7, 0xde, 0x73, 0x8f, 0x1d, 0x48, 0xaf, 0xda,
+	0x46, 0x6a, 0x2e, 0x59, 0xb1, 0x6d, 0x1b, 0xdd, 0x90, 0x50, 0x0a, 0xf9, 0xab, 0x3c, 0xfc, 0x13,
+	0xc1, 0xf8, 0x5c, 0x97, 0x7a, 0xa7, 0xc8, 0x5b, 0x48, 0x74, 0xa3, 0xcb, 0x6a, 0xcd, 0xd9, 0x35,
+	0x57, 0xd9, 0x60, 0x31, 0x38, 0x4a, 0x5e, 0x67, 0x05, 0xf2, 0x0a, 0xcb, 0x29, 0x2e, 0x0c, 0xe1,
+	0xd8, 0xd4, 0x29, 0xe8, 0xee, 0x9b, 0x7c, 0x80, 0xd9, 0x66, 0x27, 0x2a, 0xb6, 0x56, 0xba, 0x6c,
+	0x35, 0x67, 0x59, 0x80, 0xe2, 0x7c, 0x5f, 0xbc, 0x34, 0x94, 0x73, 0xcb, 0xa0, 0xd3, 0x4d, 0x0f,
+	0x91, 0x25, 0xa4, 0xb6, 0xc1, 0x95, 0x90, 0x42, 0xfd, 0xe4, 0x2c, 0x1b, 0x62, 0x87, 0xa7, 0xf7,
+	0x74, 0x38, 0x71, 0x14, 0x6a, 0x67, 0x7a, 0x48, 0xde, 0xc3, 0xd4, 0x38, 0xef, 0x3c, 0x8c, 0xb0,
+	0xc3, 0xc1, 0x7e, 0x07, 0xe3, 0xd7, 0x5b, 0x48, 0xf8, 0x2d, 0x30, 0x2b, 0xa0, 0xba, 0x33, 0x10,
+	0xde, 0xb7, 0x82, 0x91, 0x77, 0xf3, 0x71, 0x5c, 0x37, 0xfe, 0x15, 0x44, 0x35, 0x57, 0xaa, 0xbc,
+	0xe6, 0xd9, 0x18, 0xa5, 0x8f, 0xf7, 0xa5, 0xdf, 0x6c, 0x91, 0x7a, 0x56, 0xfe, 0x12, 0xe0, 0x36,
+	0x4e, 0xf2, 0xfc, 0x6e, 0xfa, 0xb3, 0x7e, 0xc6, 0xf9, 0x17, 0x98, 0xf6, 0x03, 0x24, 0x0b, 0x48,
+	0xb6, 0x65, 0x5b, 0x56, 0x15, 0xaf, 0x84, 0xaa, 0x9d, 0xa0, 0x7f, 0x44, 0x32, 0x88, 0x6e, 0x78,
+	0xbb, 0x69, 0x14, 0xc7, 0xfb, 0x98, 0x50, 0x0f, 0xf3, 0x87, 0x30, 0xdb, 0x8b, 0x32, 0xff, 0x3b,
+	0x80, 0xa4, 0x17, 0x0d, 0x49, 0x21, 0x10, 0xcc, 0xf5, 0x0c, 0x04, 0x23, 0xcf, 0x00, 0x30, 0xd6,
+	0xb5, 0x16, 0xb5, 0xed, 0x36, 0xa3, 0x31, 0x9e, 0x5c, 0x88, 0x9a, 0x93, 0x27, 0x30, 0x16, 0x72,
+	0xbb, 0xd3, 0x2a, 0x1b, 0x2e, 0x86, 0x47, 0x31, 0x75, 0xc8, 0x38, 0x68, 0x76, 0x1a, 0x0b, 0x23,
+	0x2c, 0x78, 0x48, 0x08, 0x8c, 0x18, 0x57, 0x97, 0x98, 0x72, 0x4c, 0xf1, 0xdb, 0xb0, 0x2f, 0x9b,
+	0xba, 0x2e, 0x25, 0xc3, 0x04, 0x63, 0xea, 0xa1, 0xad, 0x48, 0xd5, 0x54, 0x3c, 0x8b, 0xec, 0x26,
+	0x0e, 0xe6, 0x02, 0xa6, 0xfd, 0x3b, 0xb9, 0x63, 0xfc, 0x00, 0x26, 0x5c, 0xb2, 0xbe, 0xed, 0x88,
+	0x4b, 0xe6, 0x4d, 0x2b, 0xbc, 0x1a, 0x7c, 0x6b, 0x8f, 0xa8, 0x43, 0xe6, 0xdc, 0xba, 0xc4, 0x17,
+	0x14, 0x53, 0x87, 0xf2, 0xdf, 0x03, 0x88, 0xdc, 0x25, 0x92, 0x37, 0x10, 0x56, 0xfc, 0x86, 0x57,
+	0x38, 0x29, 0xfd, 0xff, 0x99, 0x3a, 0x56, 0xf1, 0xd5, 0x50, 0xde, 0x8d, 0x4e, 0x57, 0x27, 0x67,
+	0xd4, 0xf2, 0xcd, 0x26, 0xfe, 0x95, 0x04, 0x76, 0x47, 0x07, 0x0f, 0x5f, 0x40, 0x88, 0x7c, 0x32,
+	0x01, 0x54, 0xcc, 0x1f, 0x90, 0x04, 0xa2, 0xef, 0x1f, 0xe9, 0xea, 0x74, 0xf5, 0x69, 0x3e, 0x20,
+	0x31, 0x84, 0xc7, 0x94, 0x9e, 0xd1, 0x79, 0xb0, 0x24, 0x9f, 0x87, 0x3f, 0x52, 0x9c, 0xb8, 0xf6,
+	0x7f, 0xf5, 0xbf, 0x00, 0x00, 0x00, 0xff, 0xff, 0x2e, 0x8c, 0xef, 0xcb, 0xe0, 0x03, 0x00, 0x00,
+}
diff --git a/ui/status/ninja_frontend/frontend.proto b/ui/status/ninja_frontend/frontend.proto
new file mode 100644
index 0000000..13fd535
--- /dev/null
+++ b/ui/status/ninja_frontend/frontend.proto
@@ -0,0 +1,84 @@
+// Copyright 2017 Google Inc. All Rights Reserved.
+//
+// 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.
+
+syntax = "proto2";
+
+option optimize_for = LITE_RUNTIME;
+
+package ninja;
+option go_package = "ninja_frontend";
+
+message Status {
+  message TotalEdges {
+    // New value for total edges in the build.
+    optional uint32 total_edges = 1;
+  }
+
+  message BuildStarted {
+    // Number of jobs Ninja will run in parallel.
+    optional uint32 parallelism = 1;
+    // Verbose value passed to ninja.
+    optional bool verbose = 2;
+  }
+
+  message BuildFinished {
+  }
+
+  message EdgeStarted {
+    // Edge identification number, unique to a Ninja run.
+    optional uint32 id = 1;
+    // Edge start time in milliseconds since Ninja started.
+    optional uint32 start_time = 2;
+    // List of edge inputs.
+    repeated string inputs = 3;
+    // List of edge outputs.
+    repeated string outputs = 4;
+    // Description field from the edge.
+    optional string desc = 5;
+    // Command field from the edge.
+    optional string command = 6;
+    // Edge uses console.
+    optional bool console = 7;
+  }
+
+  message EdgeFinished {
+    // Edge identification number, unique to a Ninja run.
+    optional uint32 id = 1;
+    // Edge end time in milliseconds since Ninja started.
+    optional uint32 end_time = 2;
+    // Exit status (0 for success).
+    optional sint32 status = 3;
+    // Edge output, may contain ANSI codes.
+    optional string output = 4;
+  }
+
+  message Message {
+    enum Level {
+      INFO = 0;
+      WARNING = 1;
+      ERROR = 2;
+    }
+    // Message priority level (INFO, WARNING, or ERROR).
+    optional Level level = 1 [default = INFO];
+    // Info/warning/error message from Ninja.
+    optional string message = 2;
+  }
+
+  optional TotalEdges total_edges = 1;
+  optional BuildStarted build_started = 2;
+  optional BuildFinished build_finished = 3;
+  optional EdgeStarted edge_started = 4;
+  optional EdgeFinished edge_finished = 5;
+  optional Message message = 6;
+}
diff --git a/ui/status/ninja_frontend/regen.sh b/ui/status/ninja_frontend/regen.sh
new file mode 100755
index 0000000..d270731
--- /dev/null
+++ b/ui/status/ninja_frontend/regen.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+aprotoc --go_out=paths=source_relative:. frontend.proto
diff --git a/ui/status/status.go b/ui/status/status.go
new file mode 100644
index 0000000..c851d7f
--- /dev/null
+++ b/ui/status/status.go
@@ -0,0 +1,340 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status tracks actions run by various tools, combining the counts
+// (total actions, currently running, started, finished), and giving that to
+// multiple outputs.
+package status
+
+import (
+	"sync"
+)
+
+// Action describes an action taken (or as Ninja calls them, Edges).
+type Action struct {
+	// Description is a shorter, more readable form of the command, meant
+	// for users. It's optional, but one of either Description or Command
+	// should be set.
+	Description string
+
+	// Outputs is the (optional) list of outputs. Usually these are files,
+	// but they can be any string.
+	Outputs []string
+
+	// Command is the actual command line executed to perform the action.
+	// It's optional, but one of either Description or Command should be
+	// set.
+	Command string
+}
+
+// ActionResult describes the result of running an Action.
+type ActionResult struct {
+	// Action is a pointer to the original Action struct.
+	*Action
+
+	// Output is the output produced by the command (usually stdout&stderr
+	// for Actions that run commands)
+	Output string
+
+	// Error is nil if the Action succeeded, or set to an error if it
+	// failed.
+	Error error
+}
+
+// Counts describes the number of actions in each state
+type Counts struct {
+	// TotalActions is the total number of expected changes.  This can
+	// generally change up or down during a build, but it should never go
+	// below the number of StartedActions
+	TotalActions int
+
+	// RunningActions are the number of actions that are currently running
+	// -- the number that have called StartAction, but not FinishAction.
+	RunningActions int
+
+	// StartedActions are the number of actions that have been started with
+	// StartAction.
+	StartedActions int
+
+	// FinishedActions are the number of actions that have been finished
+	// with FinishAction.
+	FinishedActions int
+}
+
+// ToolStatus is the interface used by tools to report on their Actions, and to
+// present other information through a set of messaging functions.
+type ToolStatus interface {
+	// SetTotalActions sets the expected total number of actions that will
+	// be started by this tool.
+	//
+	// This call be will ignored if it sets a number that is less than the
+	// current number of started actions.
+	SetTotalActions(total int)
+
+	// StartAction specifies that the associated action has been started by
+	// the tool.
+	//
+	// A specific *Action should not be specified to StartAction more than
+	// once, even if the previous action has already been finished, and the
+	// contents rewritten.
+	//
+	// Do not re-use *Actions between different ToolStatus interfaces
+	// either.
+	StartAction(action *Action)
+
+	// FinishAction specifies the result of a particular Action.
+	//
+	// The *Action embedded in the ActionResult structure must have already
+	// been passed to StartAction (on this interface).
+	//
+	// Do not call FinishAction twice for the same *Action.
+	FinishAction(result ActionResult)
+
+	// Verbose takes a non-important message that is never printed to the
+	// screen, but is in the verbose build log, etc
+	Verbose(msg string)
+	// Status takes a less important message that may be printed to the
+	// screen, but overwritten by another status message. The full message
+	// will still appear in the verbose build log.
+	Status(msg string)
+	// Print takes an message and displays it to the screen and other
+	// output logs, etc.
+	Print(msg string)
+	// Error is similar to Print, but treats it similarly to a failed
+	// action, showing it in the error logs, etc.
+	Error(msg string)
+
+	// Finish marks the end of all Actions being run by this tool.
+	//
+	// SetTotalEdges, StartAction, and FinishAction should not be called
+	// after Finish.
+	Finish()
+}
+
+// MsgLevel specifies the importance of a particular log message. See the
+// descriptions in ToolStatus: Verbose, Status, Print, Error.
+type MsgLevel int
+
+const (
+	VerboseLvl MsgLevel = iota
+	StatusLvl
+	PrintLvl
+	ErrorLvl
+)
+
+func (l MsgLevel) Prefix() string {
+	switch l {
+	case VerboseLvl:
+		return "verbose: "
+	case StatusLvl:
+		return "status: "
+	case PrintLvl:
+		return ""
+	case ErrorLvl:
+		return "error: "
+	default:
+		panic("Unknown message level")
+	}
+}
+
+// StatusOutput is the interface used to get status information as a Status
+// output.
+//
+// All of the functions here are guaranteed to be called by Status while
+// holding it's internal lock, so it's safe to assume a single caller at any
+// time, and that the ordering of calls will be correct. It is not safe to call
+// back into the Status, or one of its ToolStatus interfaces.
+type StatusOutput interface {
+	// StartAction will be called once every time ToolStatus.StartAction is
+	// called. counts will include the current counters across all
+	// ToolStatus instances, including ones that have been finished.
+	StartAction(action *Action, counts Counts)
+
+	// FinishAction will be called once every time ToolStatus.FinishAction
+	// is called. counts will include the current counters across all
+	// ToolStatus instances, including ones that have been finished.
+	FinishAction(result ActionResult, counts Counts)
+
+	// Message is the equivalent of ToolStatus.Verbose/Status/Print/Error,
+	// but the level is specified as an argument.
+	Message(level MsgLevel, msg string)
+
+	// Flush is called when your outputs should be flushed / closed. No
+	// output is expected after this call.
+	Flush()
+}
+
+// Status is the multiplexer / accumulator between ToolStatus instances (via
+// StartTool) and StatusOutputs (via AddOutput). There's generally one of these
+// per build process (though tools like multiproduct_kati may have multiple
+// independent versions).
+type Status struct {
+	counts  Counts
+	outputs []StatusOutput
+
+	// Protects counts and outputs, and allows each output to
+	// expect only a single caller at a time.
+	lock sync.Mutex
+}
+
+// AddOutput attaches an output to this object. It's generally expected that an
+// output is attached to a single Status instance.
+func (s *Status) AddOutput(output StatusOutput) {
+	if output == nil {
+		return
+	}
+
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.outputs = append(s.outputs, output)
+}
+
+// StartTool returns a new ToolStatus instance to report the status of a tool.
+func (s *Status) StartTool() ToolStatus {
+	return &toolStatus{
+		status: s,
+	}
+}
+
+// Finish will call Flush on all the outputs, generally flushing or closing all
+// of their outputs. Do not call any other functions on this instance or any
+// associated ToolStatus instances after this has been called.
+func (s *Status) Finish() {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	for _, o := range s.outputs {
+		o.Flush()
+	}
+}
+
+func (s *Status) updateTotalActions(diff int) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.counts.TotalActions += diff
+}
+
+func (s *Status) startAction(action *Action) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.counts.RunningActions += 1
+	s.counts.StartedActions += 1
+
+	for _, o := range s.outputs {
+		o.StartAction(action, s.counts)
+	}
+}
+
+func (s *Status) finishAction(result ActionResult) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.counts.RunningActions -= 1
+	s.counts.FinishedActions += 1
+
+	for _, o := range s.outputs {
+		o.FinishAction(result, s.counts)
+	}
+}
+
+func (s *Status) message(level MsgLevel, msg string) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	for _, o := range s.outputs {
+		o.Message(level, msg)
+	}
+}
+
+type toolStatus struct {
+	status *Status
+
+	counts Counts
+	// Protects counts
+	lock sync.Mutex
+}
+
+var _ ToolStatus = (*toolStatus)(nil)
+
+func (d *toolStatus) SetTotalActions(total int) {
+	diff := 0
+
+	d.lock.Lock()
+	if total >= d.counts.StartedActions && total != d.counts.TotalActions {
+		diff = total - d.counts.TotalActions
+		d.counts.TotalActions = total
+	}
+	d.lock.Unlock()
+
+	if diff != 0 {
+		d.status.updateTotalActions(diff)
+	}
+}
+
+func (d *toolStatus) StartAction(action *Action) {
+	totalDiff := 0
+
+	d.lock.Lock()
+	d.counts.RunningActions += 1
+	d.counts.StartedActions += 1
+
+	if d.counts.StartedActions > d.counts.TotalActions {
+		totalDiff = d.counts.StartedActions - d.counts.TotalActions
+		d.counts.TotalActions = d.counts.StartedActions
+	}
+	d.lock.Unlock()
+
+	if totalDiff != 0 {
+		d.status.updateTotalActions(totalDiff)
+	}
+	d.status.startAction(action)
+}
+
+func (d *toolStatus) FinishAction(result ActionResult) {
+	d.lock.Lock()
+	d.counts.RunningActions -= 1
+	d.counts.FinishedActions += 1
+	d.lock.Unlock()
+
+	d.status.finishAction(result)
+}
+
+func (d *toolStatus) Verbose(msg string) {
+	d.status.message(VerboseLvl, msg)
+}
+func (d *toolStatus) Status(msg string) {
+	d.status.message(StatusLvl, msg)
+}
+func (d *toolStatus) Print(msg string) {
+	d.status.message(PrintLvl, msg)
+}
+func (d *toolStatus) Error(msg string) {
+	d.status.message(ErrorLvl, msg)
+}
+
+func (d *toolStatus) Finish() {
+	d.lock.Lock()
+	defer d.lock.Unlock()
+
+	if d.counts.TotalActions != d.counts.StartedActions {
+		d.status.updateTotalActions(d.counts.StartedActions - d.counts.TotalActions)
+	}
+
+	// TODO: update status to correct running/finished edges?
+	d.counts.RunningActions = 0
+	d.counts.TotalActions = d.counts.StartedActions
+}
diff --git a/ui/status/status_test.go b/ui/status/status_test.go
new file mode 100644
index 0000000..e62785f
--- /dev/null
+++ b/ui/status/status_test.go
@@ -0,0 +1,166 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 status
+
+import "testing"
+
+type counterOutput Counts
+
+func (c *counterOutput) StartAction(action *Action, counts Counts) {
+	*c = counterOutput(counts)
+}
+func (c *counterOutput) FinishAction(result ActionResult, counts Counts) {
+	*c = counterOutput(counts)
+}
+func (c counterOutput) Message(level MsgLevel, msg string) {}
+func (c counterOutput) Flush()                             {}
+
+func (c counterOutput) Expect(t *testing.T, counts Counts) {
+	if Counts(c) == counts {
+		return
+	}
+	t.Helper()
+
+	if c.TotalActions != counts.TotalActions {
+		t.Errorf("Expected %d total edges, but got %d", counts.TotalActions, c.TotalActions)
+	}
+	if c.RunningActions != counts.RunningActions {
+		t.Errorf("Expected %d running edges, but got %d", counts.RunningActions, c.RunningActions)
+	}
+	if c.StartedActions != counts.StartedActions {
+		t.Errorf("Expected %d started edges, but got %d", counts.StartedActions, c.StartedActions)
+	}
+	if c.FinishedActions != counts.FinishedActions {
+		t.Errorf("Expected %d finished edges, but got %d", counts.FinishedActions, c.FinishedActions)
+	}
+}
+
+func TestBasicUse(t *testing.T) {
+	status := &Status{}
+	counts := &counterOutput{}
+	status.AddOutput(counts)
+	s := status.StartTool()
+
+	s.SetTotalActions(2)
+
+	a := &Action{}
+	s.StartAction(a)
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  1,
+		StartedActions:  1,
+		FinishedActions: 0,
+	})
+
+	s.FinishAction(ActionResult{Action: a})
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  0,
+		StartedActions:  1,
+		FinishedActions: 1,
+	})
+
+	a = &Action{}
+	s.StartAction(a)
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  1,
+		StartedActions:  2,
+		FinishedActions: 1,
+	})
+
+	s.FinishAction(ActionResult{Action: a})
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  0,
+		StartedActions:  2,
+		FinishedActions: 2,
+	})
+}
+
+// For when a tool claims to have 2 actions, but finishes after one.
+func TestFinishEarly(t *testing.T) {
+	status := &Status{}
+	counts := &counterOutput{}
+	status.AddOutput(counts)
+	s := status.StartTool()
+
+	s.SetTotalActions(2)
+
+	a := &Action{}
+	s.StartAction(a)
+	s.FinishAction(ActionResult{Action: a})
+	s.Finish()
+
+	s = status.StartTool()
+	s.SetTotalActions(2)
+
+	a = &Action{}
+	s.StartAction(a)
+
+	counts.Expect(t, Counts{
+		TotalActions:    3,
+		RunningActions:  1,
+		StartedActions:  2,
+		FinishedActions: 1,
+	})
+}
+
+// For when a tool claims to have 1 action, but starts two.
+func TestExtraActions(t *testing.T) {
+	status := &Status{}
+	counts := &counterOutput{}
+	status.AddOutput(counts)
+	s := status.StartTool()
+
+	s.SetTotalActions(1)
+
+	s.StartAction(&Action{})
+	s.StartAction(&Action{})
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  2,
+		StartedActions:  2,
+		FinishedActions: 0,
+	})
+}
+
+// When a tool calls Finish() with a running Action
+func TestRunningWhenFinished(t *testing.T) {
+	status := &Status{}
+	counts := &counterOutput{}
+	status.AddOutput(counts)
+
+	s := status.StartTool()
+	s.SetTotalActions(1)
+	s.StartAction(&Action{})
+	s.Finish()
+
+	s = status.StartTool()
+	s.SetTotalActions(1)
+	s.StartAction(&Action{})
+
+	counts.Expect(t, Counts{
+		TotalActions:    2,
+		RunningActions:  2,
+		StartedActions:  2,
+		FinishedActions: 0,
+	})
+}
diff --git a/ui/terminal/Android.bp b/ui/terminal/Android.bp
new file mode 100644
index 0000000..7104a50
--- /dev/null
+++ b/ui/terminal/Android.bp
@@ -0,0 +1,37 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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.
+
+bootstrap_go_package {
+    name: "soong-ui-terminal",
+    pkgPath: "android/soong/ui/terminal",
+    deps: ["soong-ui-status"],
+    srcs: [
+        "status.go",
+        "writer.go",
+        "util.go",
+    ],
+    testSrcs: [
+        "util_test.go",
+    ],
+    darwin: {
+        srcs: [
+            "util_darwin.go",
+        ],
+    },
+    linux: {
+        srcs: [
+            "util_linux.go",
+        ],
+    },
+}
diff --git a/ui/terminal/status.go b/ui/terminal/status.go
new file mode 100644
index 0000000..c8eb382
--- /dev/null
+++ b/ui/terminal/status.go
@@ -0,0 +1,144 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 terminal
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"android/soong/ui/status"
+)
+
+type statusOutput struct {
+	writer Writer
+	format string
+
+	start time.Time
+}
+
+// NewStatusOutput returns a StatusOutput that represents the
+// current build status similarly to Ninja's built-in terminal
+// output.
+//
+// statusFormat takes nearly all the same options as NINJA_STATUS.
+// %c is currently unsupported.
+func NewStatusOutput(w Writer, statusFormat string) status.StatusOutput {
+	return &statusOutput{
+		writer: w,
+		format: statusFormat,
+
+		start: time.Now(),
+	}
+}
+
+func (s *statusOutput) Message(level status.MsgLevel, message string) {
+	if level >= status.ErrorLvl {
+		s.writer.Print(fmt.Sprintf("FAILED: %s", message))
+	} else if level > status.StatusLvl {
+		s.writer.Print(fmt.Sprintf("%s%s", level.Prefix(), message))
+	} else if level == status.StatusLvl {
+		s.writer.StatusLine(message)
+	}
+}
+
+func (s *statusOutput) StartAction(action *status.Action, counts status.Counts) {
+	if !s.writer.isSmartTerminal() {
+		return
+	}
+
+	str := action.Description
+	if str == "" {
+		str = action.Command
+	}
+
+	s.writer.StatusLine(s.progress(counts) + str)
+}
+
+func (s *statusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
+	str := result.Description
+	if str == "" {
+		str = result.Command
+	}
+
+	progress := s.progress(counts) + str
+
+	if result.Error != nil {
+		hasCommand := ""
+		if result.Command != "" {
+			hasCommand = "\n"
+		}
+
+		s.writer.StatusAndMessage(progress, fmt.Sprintf("FAILED: %s\n%s%s%s",
+			strings.Join(result.Outputs, " "), result.Command, hasCommand, result.Output))
+	} else if result.Output != "" {
+		s.writer.StatusAndMessage(progress, result.Output)
+	} else {
+		s.writer.StatusLine(progress)
+	}
+}
+
+func (s *statusOutput) Flush() {}
+
+func (s *statusOutput) progress(counts status.Counts) string {
+	if s.format == "" {
+		return fmt.Sprintf("[%3d%% %d/%d] ", 100*counts.FinishedActions/counts.TotalActions, counts.FinishedActions, counts.TotalActions)
+	}
+
+	buf := &strings.Builder{}
+	for i := 0; i < len(s.format); i++ {
+		c := s.format[i]
+		if c != '%' {
+			buf.WriteByte(c)
+			continue
+		}
+
+		i = i + 1
+		if i == len(s.format) {
+			buf.WriteByte(c)
+			break
+		}
+
+		c = s.format[i]
+		switch c {
+		case '%':
+			buf.WriteByte(c)
+		case 's':
+			fmt.Fprintf(buf, "%d", counts.StartedActions)
+		case 't':
+			fmt.Fprintf(buf, "%d", counts.TotalActions)
+		case 'r':
+			fmt.Fprintf(buf, "%d", counts.RunningActions)
+		case 'u':
+			fmt.Fprintf(buf, "%d", counts.TotalActions-counts.StartedActions)
+		case 'f':
+			fmt.Fprintf(buf, "%d", counts.FinishedActions)
+		case 'o':
+			fmt.Fprintf(buf, "%.1f", float64(counts.FinishedActions)/time.Since(s.start).Seconds())
+		case 'c':
+			// TODO: implement?
+			buf.WriteRune('?')
+		case 'p':
+			fmt.Fprintf(buf, "%3d%%", 100*counts.FinishedActions/counts.TotalActions)
+		case 'e':
+			fmt.Fprintf(buf, "%.3f", time.Since(s.start).Seconds())
+		default:
+			buf.WriteString("unknown placeholder '")
+			buf.WriteByte(c)
+			buf.WriteString("'")
+		}
+	}
+	return buf.String()
+}
diff --git a/ui/terminal/util.go b/ui/terminal/util.go
new file mode 100644
index 0000000..a85a517
--- /dev/null
+++ b/ui/terminal/util.go
@@ -0,0 +1,101 @@
+// Copyright 2017 Google Inc. All rights reserved.
+//
+// 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 terminal
+
+import (
+	"bytes"
+	"io"
+	"os"
+	"syscall"
+	"unsafe"
+)
+
+func isTerminal(w io.Writer) bool {
+	if f, ok := w.(*os.File); ok {
+		var termios syscall.Termios
+		_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(),
+			ioctlGetTermios, uintptr(unsafe.Pointer(&termios)),
+			0, 0, 0)
+		return err == 0
+	}
+	return false
+}
+
+func termWidth(w io.Writer) (int, bool) {
+	if f, ok := w.(*os.File); ok {
+		var winsize struct {
+			ws_row, ws_column    uint16
+			ws_xpixel, ws_ypixel uint16
+		}
+		_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(),
+			syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&winsize)),
+			0, 0, 0)
+		return int(winsize.ws_column), err == 0
+	}
+	return 0, false
+}
+
+// stripAnsiEscapes strips ANSI control codes from a byte array in place.
+func stripAnsiEscapes(input []byte) []byte {
+	// read represents the remaining part of input that needs to be processed.
+	read := input
+	// write represents where we should be writing in input.
+	// It will share the same backing store as input so that we make our modifications
+	// in place.
+	write := input
+
+	// advance will copy count bytes from read to write and advance those slices
+	advance := func(write, read []byte, count int) ([]byte, []byte) {
+		copy(write, read[:count])
+		return write[count:], read[count:]
+	}
+
+	for {
+		// Find the next escape sequence
+		i := bytes.IndexByte(read, 0x1b)
+		// If it isn't found, or if there isn't room for <ESC>[, finish
+		if i == -1 || i+1 >= len(read) {
+			copy(write, read)
+			break
+		}
+
+		// Not a CSI code, continue searching
+		if read[i+1] != '[' {
+			write, read = advance(write, read, i+1)
+			continue
+		}
+
+		// Found a CSI code, advance up to the <ESC>
+		write, read = advance(write, read, i)
+
+		// Find the end of the CSI code
+		i = bytes.IndexFunc(read, func(r rune) bool {
+			return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
+		})
+		if i == -1 {
+			// We didn't find the end of the code, just remove the rest
+			i = len(read) - 1
+		}
+
+		// Strip off the end marker too
+		i = i + 1
+
+		// Skip the reader forward and reduce final length by that amount
+		read = read[i:]
+		input = input[:len(input)-i]
+	}
+
+	return input
+}
diff --git a/ui/build/util_darwin.go b/ui/terminal/util_darwin.go
similarity index 97%
rename from ui/build/util_darwin.go
rename to ui/terminal/util_darwin.go
index 254a9b8..109a37f 100644
--- a/ui/build/util_darwin.go
+++ b/ui/terminal/util_darwin.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package build
+package terminal
 
 import (
 	"syscall"
diff --git a/ui/build/util_linux.go b/ui/terminal/util_linux.go
similarity index 97%
rename from ui/build/util_linux.go
rename to ui/terminal/util_linux.go
index 0a4e1d2..0a3d9dd 100644
--- a/ui/build/util_linux.go
+++ b/ui/terminal/util_linux.go
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package build
+package terminal
 
 import (
 	"syscall"
diff --git a/ui/terminal/util_test.go b/ui/terminal/util_test.go
new file mode 100644
index 0000000..82bde7c
--- /dev/null
+++ b/ui/terminal/util_test.go
@@ -0,0 +1,64 @@
+// Copyright 2017 Google Inc. All rights reserved.
+//
+// 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 terminal
+
+import (
+	"testing"
+)
+
+func TestStripAnsiEscapes(t *testing.T) {
+	testcases := []struct {
+		input  string
+		output string
+	}{
+		{
+			"",
+			"",
+		},
+		{
+			"This is a test",
+			"This is a test",
+		},
+		{
+			"interrupted: \x1b[12",
+			"interrupted: ",
+		},
+		{
+			"other \x1bescape \x1b",
+			"other \x1bescape \x1b",
+		},
+		{ // from pretty-error macro
+			"\x1b[1mart/Android.mk: \x1b[31merror:\x1b[0m\x1b[1m art: test error \x1b[0m",
+			"art/Android.mk: error: art: test error ",
+		},
+		{ // from envsetup.sh make wrapper
+			"\x1b[0;31m#### make failed to build some targets (2 seconds) ####\x1b[00m",
+			"#### make failed to build some targets (2 seconds) ####",
+		},
+		{ // from clang (via ninja testcase)
+			"\x1b[1maffixmgr.cxx:286:15: \x1b[0m\x1b[0;1;35mwarning: \x1b[0m\x1b[1musing the result... [-Wparentheses]\x1b[0m",
+			"affixmgr.cxx:286:15: warning: using the result... [-Wparentheses]",
+		},
+	}
+	for _, tc := range testcases {
+		got := string(stripAnsiEscapes([]byte(tc.input)))
+		if got != tc.output {
+			t.Errorf("output strings didn't match\n"+
+				"input: %#v\n"+
+				" want: %#v\n"+
+				"  got: %#v", tc.input, tc.output, got)
+		}
+	}
+}
diff --git a/ui/terminal/writer.go b/ui/terminal/writer.go
new file mode 100644
index 0000000..ebe4b2a
--- /dev/null
+++ b/ui/terminal/writer.go
@@ -0,0 +1,229 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 terminal provides a set of interfaces that can be used to interact
+// with the terminal (including falling back when the terminal is detected to
+// be a redirect or other dumb terminal)
+package terminal
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"sync"
+)
+
+// Writer provides an interface to write temporary and permanent messages to
+// the terminal.
+//
+// The terminal is considered to be a dumb terminal if TERM==dumb, or if a
+// terminal isn't detected on stdout/stderr (generally because it's a pipe or
+// file). Dumb terminals will strip out all ANSI escape sequences, including
+// colors.
+type Writer interface {
+	// Print prints the string to the terminal, overwriting any current
+	// status being displayed.
+	//
+	// On a dumb terminal, the status messages will be kept.
+	Print(str string)
+
+	// Status prints the first line of the string to the terminal,
+	// overwriting any previous status line. Strings longer than the width
+	// of the terminal will be cut off.
+	//
+	// On a dumb terminal, previous status messages will remain, and the
+	// entire first line of the string will be printed.
+	StatusLine(str string)
+
+	// StatusAndMessage prints the first line of status to the terminal,
+	// similarly to StatusLine(), then prints the full msg below that. The
+	// status line is retained.
+	//
+	// There is guaranteed to be no other output in between the status and
+	// message.
+	StatusAndMessage(status, msg string)
+
+	// Finish ensures that the output ends with a newline (preserving any
+	// current status line that is current displayed).
+	//
+	// This does nothing on dumb terminals.
+	Finish()
+
+	// Write implements the io.Writer interface. This is primarily so that
+	// the logger can use this interface to print to stderr without
+	// breaking the other semantics of this interface.
+	//
+	// Try to use any of the other functions if possible.
+	Write(p []byte) (n int, err error)
+
+	isSmartTerminal() bool
+}
+
+// NewWriter creates a new Writer based on the stdio and the TERM
+// environment variable.
+func NewWriter(stdio StdioInterface) Writer {
+	w := &writerImpl{
+		stdio: stdio,
+
+		haveBlankLine: true,
+	}
+
+	if term, ok := os.LookupEnv("TERM"); ok && term != "dumb" {
+		w.smartTerminal = isTerminal(stdio.Stdout())
+	}
+	w.stripEscapes = !w.smartTerminal
+
+	return w
+}
+
+type writerImpl struct {
+	stdio StdioInterface
+
+	haveBlankLine bool
+
+	// Protecting the above, we assume that smartTerminal and stripEscapes
+	// does not change after initial setup.
+	lock sync.Mutex
+
+	smartTerminal bool
+	stripEscapes  bool
+}
+
+func (w *writerImpl) isSmartTerminal() bool {
+	return w.smartTerminal
+}
+
+func (w *writerImpl) requestLine() {
+	if !w.haveBlankLine {
+		fmt.Fprintln(w.stdio.Stdout())
+		w.haveBlankLine = true
+	}
+}
+
+func (w *writerImpl) Print(str string) {
+	if w.stripEscapes {
+		str = string(stripAnsiEscapes([]byte(str)))
+	}
+
+	w.lock.Lock()
+	defer w.lock.Unlock()
+	w.print(str)
+}
+
+func (w *writerImpl) print(str string) {
+	if !w.haveBlankLine {
+		fmt.Fprint(w.stdio.Stdout(), "\r", "\x1b[K")
+		w.haveBlankLine = true
+	}
+	fmt.Fprint(w.stdio.Stdout(), str)
+	if len(str) == 0 || str[len(str)-1] != '\n' {
+		fmt.Fprint(w.stdio.Stdout(), "\n")
+	}
+}
+
+func (w *writerImpl) StatusLine(str string) {
+	w.lock.Lock()
+	defer w.lock.Unlock()
+
+	w.statusLine(str)
+}
+
+func (w *writerImpl) statusLine(str string) {
+	if !w.smartTerminal {
+		fmt.Fprintln(w.stdio.Stdout(), str)
+		return
+	}
+
+	idx := strings.IndexRune(str, '\n')
+	if idx != -1 {
+		str = str[0:idx]
+	}
+
+	// Limit line width to the terminal width, otherwise we'll wrap onto
+	// another line and we won't delete the previous line.
+	//
+	// Run this on every line in case the window has been resized while
+	// we're printing. This could be optimized to only re-run when we get
+	// SIGWINCH if it ever becomes too time consuming.
+	if max, ok := termWidth(w.stdio.Stdout()); ok {
+		if len(str) > max {
+			// TODO: Just do a max. Ninja elides the middle, but that's
+			// more complicated and these lines aren't that important.
+			str = str[:max]
+		}
+	}
+
+	// Move to the beginning on the line, print the output, then clear
+	// the rest of the line.
+	fmt.Fprint(w.stdio.Stdout(), "\r", str, "\x1b[K")
+	w.haveBlankLine = false
+}
+
+func (w *writerImpl) StatusAndMessage(status, msg string) {
+	if w.stripEscapes {
+		msg = string(stripAnsiEscapes([]byte(msg)))
+	}
+
+	w.lock.Lock()
+	defer w.lock.Unlock()
+
+	w.statusLine(status)
+	w.requestLine()
+	w.print(msg)
+}
+
+func (w *writerImpl) Finish() {
+	w.lock.Lock()
+	defer w.lock.Unlock()
+
+	w.requestLine()
+}
+
+func (w *writerImpl) Write(p []byte) (n int, err error) {
+	w.Print(string(p))
+	return len(p), nil
+}
+
+// StdioInterface represents a set of stdin/stdout/stderr Reader/Writers
+type StdioInterface interface {
+	Stdin() io.Reader
+	Stdout() io.Writer
+	Stderr() io.Writer
+}
+
+// StdioImpl uses the OS stdin/stdout/stderr to implement StdioInterface
+type StdioImpl struct{}
+
+func (StdioImpl) Stdin() io.Reader  { return os.Stdin }
+func (StdioImpl) Stdout() io.Writer { return os.Stdout }
+func (StdioImpl) Stderr() io.Writer { return os.Stderr }
+
+var _ StdioInterface = StdioImpl{}
+
+type customStdio struct {
+	stdin  io.Reader
+	stdout io.Writer
+	stderr io.Writer
+}
+
+func NewCustomStdio(stdin io.Reader, stdout, stderr io.Writer) StdioInterface {
+	return customStdio{stdin, stdout, stderr}
+}
+
+func (c customStdio) Stdin() io.Reader  { return c.stdin }
+func (c customStdio) Stdout() io.Writer { return c.stdout }
+func (c customStdio) Stderr() io.Writer { return c.stderr }
+
+var _ StdioInterface = customStdio{}
diff --git a/ui/tracer/Android.bp b/ui/tracer/Android.bp
index 9729c7e..af588f1 100644
--- a/ui/tracer/Android.bp
+++ b/ui/tracer/Android.bp
@@ -15,10 +15,13 @@
 bootstrap_go_package {
     name: "soong-ui-tracer",
     pkgPath: "android/soong/ui/tracer",
-    deps: ["soong-ui-logger"],
+    deps: [
+        "soong-ui-logger",
+        "soong-ui-status",
+    ],
     srcs: [
         "microfactory.go",
-        "ninja.go",
+        "status.go",
         "tracer.go",
     ],
 }
diff --git a/ui/tracer/microfactory.go b/ui/tracer/microfactory.go
index acb9be4..c4c37c2 100644
--- a/ui/tracer/microfactory.go
+++ b/ui/tracer/microfactory.go
@@ -17,10 +17,48 @@
 import (
 	"bufio"
 	"os"
+	"sort"
 	"strconv"
 	"strings"
 )
 
+type eventEntry struct {
+	Name  string
+	Begin uint64
+	End   uint64
+}
+
+func (t *tracerImpl) importEvents(entries []*eventEntry) {
+	sort.Slice(entries, func(i, j int) bool {
+		return entries[i].Begin < entries[j].Begin
+	})
+
+	cpus := []uint64{}
+	for _, entry := range entries {
+		tid := -1
+		for cpu, endTime := range cpus {
+			if endTime <= entry.Begin {
+				tid = cpu
+				cpus[cpu] = entry.End
+				break
+			}
+		}
+		if tid == -1 {
+			tid = len(cpus)
+			cpus = append(cpus, entry.End)
+		}
+
+		t.writeEvent(&viewerEvent{
+			Name:  entry.Name,
+			Phase: "X",
+			Time:  entry.Begin,
+			Dur:   entry.End - entry.Begin,
+			Pid:   1,
+			Tid:   uint64(tid),
+		})
+	}
+}
+
 func (t *tracerImpl) ImportMicrofactoryLog(filename string) {
 	if _, err := os.Stat(filename); err != nil {
 		return
diff --git a/ui/tracer/ninja.go b/ui/tracer/ninja.go
deleted file mode 100644
index 1980559..0000000
--- a/ui/tracer/ninja.go
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright 2016 Google Inc. All rights reserved.
-//
-// 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 tracer
-
-import (
-	"bufio"
-	"os"
-	"sort"
-	"strconv"
-	"strings"
-	"time"
-)
-
-type eventEntry struct {
-	Name  string
-	Begin uint64
-	End   uint64
-}
-
-func (t *tracerImpl) importEvents(entries []*eventEntry) {
-	sort.Slice(entries, func(i, j int) bool {
-		return entries[i].Begin < entries[j].Begin
-	})
-
-	cpus := []uint64{}
-	for _, entry := range entries {
-		tid := -1
-		for cpu, endTime := range cpus {
-			if endTime <= entry.Begin {
-				tid = cpu
-				cpus[cpu] = entry.End
-				break
-			}
-		}
-		if tid == -1 {
-			tid = len(cpus)
-			cpus = append(cpus, entry.End)
-		}
-
-		t.writeEvent(&viewerEvent{
-			Name:  entry.Name,
-			Phase: "X",
-			Time:  entry.Begin,
-			Dur:   entry.End - entry.Begin,
-			Pid:   1,
-			Tid:   uint64(tid),
-		})
-	}
-}
-
-// ImportNinjaLog reads a .ninja_log file from ninja and writes the events out
-// to the trace.
-//
-// startOffset is when the ninja process started, and is used to position the
-// relative times from the ninja log into the trace. It's also used to skip
-// reading the ninja log if nothing was run.
-func (t *tracerImpl) ImportNinjaLog(thread Thread, filename string, startOffset time.Time) {
-	t.Begin("ninja log import", thread)
-	defer t.End(thread)
-
-	if stat, err := os.Stat(filename); err != nil {
-		t.log.Println("Missing ninja log:", err)
-		return
-	} else if stat.ModTime().Before(startOffset) {
-		t.log.Verboseln("Ninja log not modified, not importing any entries.")
-		return
-	}
-
-	f, err := os.Open(filename)
-	if err != nil {
-		t.log.Println("Error opening ninja log:", err)
-		return
-	}
-	defer f.Close()
-
-	s := bufio.NewScanner(f)
-	header := true
-	entries := []*eventEntry{}
-	prevEnd := 0
-	offset := uint64(startOffset.UnixNano()) / 1000
-	for s.Scan() {
-		if header {
-			hdr := s.Text()
-			if hdr != "# ninja log v5" {
-				t.log.Printf("Unknown ninja log header: %q", hdr)
-				return
-			}
-			header = false
-			continue
-		}
-
-		fields := strings.Split(s.Text(), "\t")
-		begin, err := strconv.Atoi(fields[0])
-		if err != nil {
-			t.log.Printf("Unable to parse ninja entry %q: %v", s.Text(), err)
-			return
-		}
-		end, err := strconv.Atoi(fields[1])
-		if err != nil {
-			t.log.Printf("Unable to parse ninja entry %q: %v", s.Text(), err)
-			return
-		}
-		if end < prevEnd {
-			entries = nil
-		}
-		prevEnd = end
-		entries = append(entries, &eventEntry{
-			Name:  fields[3],
-			Begin: offset + uint64(begin)*1000,
-			End:   offset + uint64(end)*1000,
-		})
-	}
-	if err := s.Err(); err != nil {
-		t.log.Println("Unable to parse ninja log:", err)
-		return
-	}
-
-	t.importEvents(entries)
-}
diff --git a/ui/tracer/status.go b/ui/tracer/status.go
new file mode 100644
index 0000000..af50e2d
--- /dev/null
+++ b/ui/tracer/status.go
@@ -0,0 +1,87 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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 tracer
+
+import (
+	"android/soong/ui/status"
+	"time"
+)
+
+func (t *tracerImpl) StatusTracer() status.StatusOutput {
+	return &statusOutput{
+		tracer: t,
+
+		running: map[*status.Action]actionStatus{},
+	}
+}
+
+type actionStatus struct {
+	cpu   int
+	start time.Time
+}
+
+type statusOutput struct {
+	tracer *tracerImpl
+
+	cpus    []bool
+	running map[*status.Action]actionStatus
+}
+
+func (s *statusOutput) StartAction(action *status.Action, counts status.Counts) {
+	cpu := -1
+	for i, busy := range s.cpus {
+		if !busy {
+			cpu = i
+			s.cpus[i] = true
+			break
+		}
+	}
+
+	if cpu == -1 {
+		cpu = len(s.cpus)
+		s.cpus = append(s.cpus, true)
+	}
+
+	s.running[action] = actionStatus{
+		cpu:   cpu,
+		start: time.Now(),
+	}
+}
+
+func (s *statusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
+	start, ok := s.running[result.Action]
+	if !ok {
+		return
+	}
+	delete(s.running, result.Action)
+	s.cpus[start.cpu] = false
+
+	str := result.Action.Description
+	if len(result.Action.Outputs) > 0 {
+		str = result.Action.Outputs[0]
+	}
+
+	s.tracer.writeEvent(&viewerEvent{
+		Name:  str,
+		Phase: "X",
+		Time:  uint64(start.start.UnixNano()) / 1000,
+		Dur:   uint64(time.Since(start.start).Nanoseconds()) / 1000,
+		Pid:   1,
+		Tid:   uint64(start.cpu),
+	})
+}
+
+func (s *statusOutput) Flush()                                        {}
+func (s *statusOutput) Message(level status.MsgLevel, message string) {}
diff --git a/ui/tracer/tracer.go b/ui/tracer/tracer.go
index 8705040..b8fc87b 100644
--- a/ui/tracer/tracer.go
+++ b/ui/tracer/tracer.go
@@ -31,6 +31,7 @@
 	"time"
 
 	"android/soong/ui/logger"
+	"android/soong/ui/status"
 )
 
 type Thread uint64
@@ -46,7 +47,8 @@
 	Complete(name string, thread Thread, begin, end uint64)
 
 	ImportMicrofactoryLog(filename string)
-	ImportNinjaLog(thread Thread, filename string, startOffset time.Time)
+
+	StatusTracer() status.StatusOutput
 
 	NewThread(name string) Thread
 }