tp: add terminal-aware progress and color controls

Share throttled stderr progress reporting across trace loading,
bundling, and conversions. Pass --no-progress explicitly through
callers, preserve ordinary diagnostics, and support FORCE_COLOR and
NO_COLOR independently of progress.
diff --git a/Android.bp b/Android.bp
index bc9d489..e7e6672 100644
--- a/Android.bp
+++ b/Android.bp
@@ -15952,6 +15952,7 @@
         "src/base/paged_memory.cc",
         "src/base/periodic_task.cc",
         "src/base/pipe.cc",
+        "src/base/progress_reporter.cc",
         "src/base/rt_mutex.cc",
         "src/base/scoped_mmap.cc",
         "src/base/scoped_sched_boost.cc",
@@ -16069,6 +16070,7 @@
         "src/base/no_destructor_unittest.cc",
         "src/base/paged_memory_unittest.cc",
         "src/base/periodic_task_unittest.cc",
+        "src/base/progress_reporter_unittest.cc",
         "src/base/regex/regex_unittest.cc",
         "src/base/rt_mutex_unittest.cc",
         "src/base/scoped_file_unittest.cc",
diff --git a/BUILD b/BUILD
index 43be48b..6e664d7 100644
--- a/BUILD
+++ b/BUILD
@@ -1436,6 +1436,7 @@
         "include/perfetto/ext/base/periodic_task.h",
         "include/perfetto/ext/base/pipe.h",
         "include/perfetto/ext/base/platform.h",
+        "include/perfetto/ext/base/progress_reporter.h",
         "include/perfetto/ext/base/rt_mutex.h",
         "include/perfetto/ext/base/scoped_file.h",
         "include/perfetto/ext/base/scoped_mmap.h",
@@ -1977,6 +1978,7 @@
         "src/base/paged_memory.cc",
         "src/base/periodic_task.cc",
         "src/base/pipe.cc",
+        "src/base/progress_reporter.cc",
         "src/base/rt_mutex.cc",
         "src/base/scoped_mmap.cc",
         "src/base/scoped_sched_boost.cc",
diff --git a/docs/learning-more/symbolization.md b/docs/learning-more/symbolization.md
index 25a388e..f0eeaf1 100644
--- a/docs/learning-more/symbolization.md
+++ b/docs/learning-more/symbolization.md
@@ -112,8 +112,17 @@
 `PERFETTO_BINARY_PATH` still apply; unset that variable to restrict lookup to
 `--symbol-paths`.
 
+For scripts that capture diagnostics, redirect stderr to a log:
+
+```bash
+trace_processor bundle input.perfetto-trace enriched-trace 2>bundle.log
+```
+
+Live progress is suppressed when stderr is redirected. Use `--no-progress` to
+suppress it when running in a terminal too.
+
 See the [bundle command reference](/docs/reference/trace-processor-cli.md#subcommand-bundle)
-for option semantics, symbol search paths, and exit status.
+for option semantics, color controls, and exit status.
 
 ### {#option-2-legacy-traceconv-symbolize-deobfuscate} Option 2: Legacy `trace_processor util symbolize` / `util deobfuscate`
 
diff --git a/docs/reference/trace-processor-cli.md b/docs/reference/trace-processor-cli.md
index 8f6055c..5c90ab6 100644
--- a/docs/reference/trace-processor-cli.md
+++ b/docs/reference/trace-processor-cli.md
@@ -30,6 +30,8 @@
 and behave the same across all subcommands:
 
 - **Help and version:** `-h, --help`, `-v, --version`.
+- **Progress:** `--no-progress` disables live progress, preserving summaries,
+  warnings, and errors.
 - **Trace ingestion:** `--full-sort`, `--no-ftrace-raw`,
   `--analyze-trace-proto-content`, `--crop-track-events`.
 - **PerfettoSQL packages:** `--add-sql-package PATH[@PKG]`,
@@ -44,6 +46,25 @@
   trace processor itself, which you can load back into the UI for
   performance debugging.
 
+## Progress and diagnostics
+
+Diagnostics go to stderr. Live progress is displayed only when stderr is a
+terminal and `TERM` is not `dumb`. Redirected stderr contains ordinary messages
+without progress redraws. `--no-progress` suppresses live progress independently
+of verbosity and color; summaries, warnings, and errors remain enabled.
+
+## Color environment variables
+
+| Environment | Behavior |
+| --- | --- |
+| Nonempty `FORCE_COLOR` | Force ANSI color, including when stderr is redirected. Takes precedence over `NO_COLOR`. |
+| Nonempty `NO_COLOR` | Disable automatic color. |
+| Neither | On POSIX, use terminal detection and disable automatic color for `TERM=dumb`. On Windows, automatic ANSI color is disabled. |
+
+Empty values are ignored. Any nonempty value counts, including `0`, following
+[FORCE_COLOR](https://force-color.org/) and [NO_COLOR](https://no-color.org/).
+Forcing color does not enable progress redraws.
+
 ## {#subcommands} Commands
 
 | Command | Purpose |
@@ -230,7 +251,8 @@
 
 Formats are `systrace`, `json`, `ctrace`, `text`, `profile`, and `firefox`.
 Omitted input and output paths use stdin and stdout. For `profile`, use
-`--output-dir` instead of an output-file argument. Run `help convert` for
+`--output-dir` instead of an output-file argument. `--no-progress` applies
+to conversion progress as well as trace loading. Run `help convert` for
 format-specific options.
 
 ### {#subcommand-util} `util`: low-level trace utilities
diff --git a/include/perfetto/ext/base/BUILD.gn b/include/perfetto/ext/base/BUILD.gn
index 6539089..9fc0876 100644
--- a/include/perfetto/ext/base/BUILD.gn
+++ b/include/perfetto/ext/base/BUILD.gn
@@ -48,6 +48,7 @@
     "periodic_task.h",
     "pipe.h",
     "platform.h",
+    "progress_reporter.h",
     "rt_mutex.h",
     "scoped_file.h",
     "scoped_mmap.h",
diff --git a/include/perfetto/ext/base/file_utils.h b/include/perfetto/ext/base/file_utils.h
index a69c2ec..a5a8353 100644
--- a/include/perfetto/ext/base/file_utils.h
+++ b/include/perfetto/ext/base/file_utils.h
@@ -101,6 +101,9 @@
 
 bool FlushFile(int fd);
 
+// Duplicates an open descriptor. The duplicate is not inherited across exec.
+ScopedFile DupFile(int fd);
+
 // Moves the file offset to |offset| bytes from the beginning of the file.
 // Returns false if |offset| cannot be represented by the platform or the seek
 // fails.
diff --git a/include/perfetto/ext/base/progress_reporter.h b/include/perfetto/ext/base/progress_reporter.h
new file mode 100644
index 0000000..146e918
--- /dev/null
+++ b/include/perfetto/ext/base/progress_reporter.h
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#ifndef INCLUDE_PERFETTO_EXT_BASE_PROGRESS_REPORTER_H_
+#define INCLUDE_PERFETTO_EXT_BASE_PROGRESS_REPORTER_H_
+
+#include <cstdint>
+#include <string>
+
+namespace perfetto::base {
+
+bool StderrSupportsProgress();
+bool StderrSupportsColor();
+
+// A single-line, throttled stderr display. Updates must be plain ASCII without
+// terminal escapes. No output is produced for redirected stderr or TERM=dumb.
+// Use Clear() before ordinary stdio diagnostics; Perfetto logs clear it
+// automatically. This class does not print a final summary.
+class ProgressReporter {
+ public:
+  explicit ProgressReporter(bool enabled = true);
+  ~ProgressReporter();
+  ProgressReporter(const ProgressReporter&) = delete;
+  ProgressReporter& operator=(const ProgressReporter&) = delete;
+
+  void Update(const std::string& message);
+  void Clear();
+  static void ClearBeforeLog();
+
+ private:
+  void ClearLocked();
+  bool enabled_;
+  int64_t last_update_ms_ = 0;
+  size_t visible_width_ = 0;
+};
+
+}  // namespace perfetto::base
+#endif  // INCLUDE_PERFETTO_EXT_BASE_PROGRESS_REPORTER_H_
diff --git a/src/base/BUILD.gn b/src/base/BUILD.gn
index bc99f55..8866d32 100644
--- a/src/base/BUILD.gn
+++ b/src/base/BUILD.gn
@@ -51,6 +51,7 @@
     "paged_memory.cc",
     "periodic_task.cc",
     "pipe.cc",
+    "progress_reporter.cc",
     "rt_mutex.cc",
     "scoped_mmap.cc",
     "scoped_sched_boost.cc",
@@ -244,6 +245,7 @@
     "no_destructor_unittest.cc",
     "paged_memory_unittest.cc",
     "periodic_task_unittest.cc",
+    "progress_reporter_unittest.cc",
     "regex/regex_unittest.cc",
     "rt_mutex_unittest.cc",
     "scoped_file_unittest.cc",
diff --git a/src/base/file_utils.cc b/src/base/file_utils.cc
index fc4b53a..a0d8521 100644
--- a/src/base/file_utils.cc
+++ b/src/base/file_utils.cc
@@ -266,6 +266,19 @@
 #endif
 }
 
+ScopedFile DupFile(int fd) {
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  ScopedFile duplicate(_dup(fd));
+  if (duplicate && !SetHandleInformation(
+                       reinterpret_cast<HANDLE>(_get_osfhandle(*duplicate)),
+                       HANDLE_FLAG_INHERIT, 0))
+    return ScopedFile();
+  return duplicate;
+#else
+  return ScopedFile(fcntl(fd, F_DUPFD_CLOEXEC, 0));
+#endif
+}
+
 bool SeekFile(int fd, uint64_t offset) {
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
   if (fd < 0) {
diff --git a/src/base/logging.cc b/src/base/logging.cc
index e5a1db2..daa2371 100644
--- a/src/base/logging.cc
+++ b/src/base/logging.cc
@@ -15,6 +15,7 @@
  */
 
 #include "perfetto/base/logging.h"
+#include "perfetto/ext/base/progress_reporter.h"
 
 #include <stdarg.h>
 #include <stdio.h>
@@ -126,6 +127,7 @@
     log_msg = &large_buf[0];
   }
 
+  ProgressReporter::ClearBeforeLog();
   LogMessageCallback cb = g_log_callback.load(std::memory_order_relaxed);
   if (cb) {
     cb({level, line, fname, log_msg});
@@ -148,13 +150,7 @@
       break;
   }
 
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) &&  \
-    !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM) && \
-    !PERFETTO_BUILDFLAG(PERFETTO_CHROMIUM_BUILD)
-  static const bool use_colors = isatty(STDERR_FILENO);
-#else
-  static const bool use_colors = false;
-#endif
+  const bool use_colors = StderrSupportsColor();
 
   // Formats file.cc:line as a space-padded fixed width string. If the file name
   // |fname| is too long, truncate it on the left-hand side.
diff --git a/src/base/progress_reporter.cc b/src/base/progress_reporter.cc
new file mode 100644
index 0000000..c683927
--- /dev/null
+++ b/src/base/progress_reporter.cc
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#include "perfetto/ext/base/progress_reporter.h"
+
+#include <algorithm>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <mutex>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/time.h"
+#include "perfetto/ext/base/no_destructor.h"
+#include "perfetto/ext/base/utils.h"
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+#include <windows.h>
+#elif !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
+#include <sys/ioctl.h>
+#include <unistd.h>
+#endif
+
+namespace perfetto::base {
+namespace {
+std::mutex& Mutex() {
+  static NoDestructor<std::mutex> mutex;
+  return mutex.ref();
+}
+ProgressReporter* g_visible_reporter = nullptr;
+
+size_t TerminalWidth() {
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  CONSOLE_SCREEN_BUFFER_INFO info{};
+  if (GetConsoleScreenBufferInfo(GetStdHandle(STD_ERROR_HANDLE), &info))
+    return static_cast<size_t>(info.srWindow.Right - info.srWindow.Left + 1);
+#elif !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
+  struct winsize size{};
+  if (ioctl(STDERR_FILENO, TIOCGWINSZ, &size) == 0 && size.ws_col)
+    return size.ws_col;
+#endif
+  return 80;
+}
+}  // namespace
+
+bool StderrSupportsProgress() {
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
+  return false;
+#else
+  const char* term = getenv("TERM");
+  return IsTty(stderr) && (!term || strcmp(term, "dumb") != 0);
+#endif
+}
+
+bool StderrSupportsColor() {
+  const char* force_color = getenv("FORCE_COLOR");
+  if (force_color && *force_color)
+    return true;
+  const char* no_color = getenv("NO_COLOR");
+  if (no_color && *no_color)
+    return false;
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  // Do not emit ANSI colors without first enabling virtual terminal output.
+  return false;
+#elif PERFETTO_BUILDFLAG(PERFETTO_CHROMIUM_BUILD)
+  return false;
+#else
+  return StderrSupportsProgress();
+#endif
+}
+
+ProgressReporter::ProgressReporter(bool enabled) : enabled_(enabled) {}
+
+ProgressReporter::~ProgressReporter() {
+  Clear();
+}
+
+void ProgressReporter::Update(const std::string& message) {
+  if (!enabled_ || !StderrSupportsProgress())
+    return;
+  std::lock_guard<std::mutex> lock(Mutex());
+  int64_t now = GetWallTimeMs().count();
+  if (last_update_ms_ && now - last_update_ms_ < 100)
+    return;
+  last_update_ms_ = now;
+  if (g_visible_reporter)
+    g_visible_reporter->ClearLocked();
+  // Leave one column unused to avoid wrapping, including on narrow terminals.
+  size_t width = TerminalWidth();
+  visible_width_ = std::min(message.size(), width > 1 ? width - 1 : 0);
+  if (!visible_width_)
+    return;
+  fprintf(stderr, "\r%.*s", static_cast<int>(visible_width_), message.c_str());
+  fflush(stderr);
+  g_visible_reporter = this;
+}
+
+void ProgressReporter::ClearLocked() {
+  if (!visible_width_)
+    return;
+  // Bound clearing by the current width in case the terminal was resized.
+  size_t width = TerminalWidth();
+  size_t clear = std::min(visible_width_, width > 1 ? width - 1 : 0);
+  fprintf(stderr, "\r%*s\r", static_cast<int>(clear), "");
+  fflush(stderr);
+  visible_width_ = 0;
+  g_visible_reporter = nullptr;
+}
+
+void ProgressReporter::Clear() {
+  std::lock_guard<std::mutex> lock(Mutex());
+  ClearLocked();
+}
+
+void ProgressReporter::ClearBeforeLog() {
+  std::lock_guard<std::mutex> lock(Mutex());
+  if (g_visible_reporter)
+    g_visible_reporter->ClearLocked();
+}
+}  // namespace perfetto::base
diff --git a/src/base/progress_reporter_unittest.cc b/src/base/progress_reporter_unittest.cc
new file mode 100644
index 0000000..3f00735
--- /dev/null
+++ b/src/base/progress_reporter_unittest.cc
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * 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.
+ */
+
+#include "perfetto/ext/base/progress_reporter.h"
+
+#include <cstdlib>
+#include <functional>
+#include <map>
+#include <optional>
+#include <string>
+
+#include "perfetto/base/build_config.h"
+#include "perfetto/base/logging.h"
+#include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/utils.h"
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX)
+#include <sys/ioctl.h>
+#include <unistd.h>
+#endif
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::base {
+namespace {
+class ProgressReporterTest : public testing::Test {
+ protected:
+  void SetUp() override {
+    for (const char* name : {"TERM", "NO_COLOR", "FORCE_COLOR"}) {
+      const char* value = getenv(name);
+      saved_[name] = value ? std::optional<std::string>(value) : std::nullopt;
+      UnsetEnv(name);
+    }
+    testing::internal::CaptureStderr();
+  }
+  void TearDown() override {
+    if (!captured_)
+      testing::internal::GetCapturedStderr();
+    for (const auto& item : saved_) {
+      if (item.second)
+        SetEnv(item.first, *item.second);
+      else
+        UnsetEnv(item.first);
+    }
+  }
+  std::string Output() {
+    captured_ = true;
+    return testing::internal::GetCapturedStderr();
+  }
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX)
+  std::string OnTerminal(const std::function<void()>& action) {
+    auto master = OpenFile("/dev/ptmx", O_RDWR | O_NOCTTY | O_NONBLOCK);
+    PERFETTO_CHECK(master);
+    PERFETTO_CHECK(grantpt(*master) == 0);
+    PERFETTO_CHECK(unlockpt(*master) == 0);
+    auto slave = OpenFile(ptsname(*master), O_RDWR | O_NOCTTY);
+    PERFETTO_CHECK(slave);
+    struct winsize size{};
+    size.ws_col = 20;
+    PERFETTO_CHECK(ioctl(*slave, TIOCSWINSZ, &size) == 0);
+    auto saved_stderr = DupFile(STDERR_FILENO);
+    PERFETTO_CHECK(saved_stderr);
+    fflush(stderr);
+    PERFETTO_CHECK(dup2(*slave, STDERR_FILENO) == STDERR_FILENO);
+    action();
+    fflush(stderr);
+    PERFETTO_CHECK(dup2(*saved_stderr, STDERR_FILENO) == STDERR_FILENO);
+    std::string output;
+    char buffer[1024];
+    for (;;) {
+      ssize_t size_read = read(*master, buffer, sizeof(buffer));
+      if (size_read <= 0)
+        break;
+      output.append(buffer, static_cast<size_t>(size_read));
+    }
+    return output;
+  }
+#endif
+
+ private:
+  bool captured_ = false;
+  std::map<std::string, std::optional<std::string>> saved_;
+};
+
+TEST_F(ProgressReporterTest, RedirectedOutputHasNoProgressOrColor) {
+  EXPECT_FALSE(StderrSupportsProgress());
+  EXPECT_FALSE(StderrSupportsColor());
+  {
+    ProgressReporter progress;
+    progress.Update("Loading trace: 1 MB");
+  }
+  EXPECT_TRUE(Output().empty());
+}
+
+TEST_F(ProgressReporterTest, ForceColorDoesNotForceProgress) {
+  // Any nonempty value, including "0", forces ANSI color.
+  SetEnv("FORCE_COLOR", "0");
+  SetEnv("TERM", "dumb");
+  EXPECT_TRUE(StderrSupportsColor());
+  EXPECT_FALSE(StderrSupportsProgress());
+  {
+    ProgressReporter progress;
+    progress.Update("Loading trace: 1 MB");
+  }
+  EXPECT_TRUE(Output().empty());
+}
+
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX)
+TEST_F(ProgressReporterTest, TerminalClipsAndClearsBeforeLogging) {
+  SetEnv("TERM", "xterm");
+  SetEnv("NO_COLOR", "1");
+  auto output = OnTerminal([] {
+    EXPECT_TRUE(StderrSupportsProgress());
+    EXPECT_FALSE(StderrSupportsColor());
+    ProgressReporter progress;
+    progress.Update("1234567890123456789overflow");
+    PERFETTO_LOG("diagnostic");
+  });
+  EXPECT_THAT(output, testing::HasSubstr("\r1234567890123456789"));
+  EXPECT_THAT(output, testing::Not(testing::HasSubstr("overflow")));
+  EXPECT_THAT(output, testing::HasSubstr("\r                   \r"));
+  EXPECT_THAT(output, testing::HasSubstr("diagnostic"));
+}
+
+TEST_F(ProgressReporterTest, TerminalSuppressionDoesNotSuppressDiagnostics) {
+  SetEnv("TERM", "xterm");
+  auto output = OnTerminal([] {
+    ProgressReporter progress(false);
+    progress.Update("hidden progress");
+    fprintf(stderr, "visible diagnostic");
+  });
+  EXPECT_EQ(output, "visible diagnostic");
+  SetEnv("TERM", "dumb");
+  EXPECT_TRUE(OnTerminal([] {
+                EXPECT_FALSE(StderrSupportsProgress());
+                ProgressReporter progress;
+                progress.Update("hidden progress");
+              }).empty());
+}
+#endif
+
+TEST_F(ProgressReporterTest, ForceColorOverridesNoColor) {
+  SetEnv("NO_COLOR", "1");
+  EXPECT_FALSE(StderrSupportsColor());
+  SetEnv("FORCE_COLOR", "1");
+  EXPECT_TRUE(StderrSupportsColor());
+  SetEnv("FORCE_COLOR", "");
+  EXPECT_FALSE(StderrSupportsColor());
+}
+}  // namespace
+}  // namespace perfetto::base
diff --git a/src/trace_processor/shell/bundle_integrationtest.cc b/src/trace_processor/shell/bundle_integrationtest.cc
index b2e89f9..0756714 100644
--- a/src/trace_processor/shell/bundle_integrationtest.cc
+++ b/src/trace_processor/shell/bundle_integrationtest.cc
@@ -638,5 +638,27 @@
   EXPECT_NE(invoker.Run(), 0);
 }
 
+#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+TEST_F(TraceconvShellBundleTest, RedirectedProgressIsPlainAndWarningsRemain) {
+  base::TempFile trace = WriteTempFile(BuildFuncgraphTrace(false));
+  for (bool no_progress : {false, true}) {
+    ArgvInvoker invoker;
+    invoker.Add("trace_processor_shell");
+    invoker.Add("bundle");
+    invoker.Add("--no-auto-symbol-paths");
+    if (no_progress)
+      invoker.Add("--no-progress");
+    invoker.Add(trace.path());
+    invoker.Add(output_path_);
+    ScopedStderrCapture capture;
+    ASSERT_EQ(invoker.Run(), 0);
+    auto output = capture.Get();
+    EXPECT_THAT(output, Not(HasSubstr("\r")));
+    EXPECT_THAT(output, HasSubstr("Read trace:"));
+    EXPECT_THAT(output, HasSubstr("symbolize_ksyms"));
+  }
+}
+#endif
+
 }  // namespace
 }  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/shell/bundle_subcommand.cc b/src/trace_processor/shell/bundle_subcommand.cc
index 88fc42c..2ea702b 100644
--- a/src/trace_processor/shell/bundle_subcommand.cc
+++ b/src/trace_processor/shell/bundle_subcommand.cc
@@ -26,6 +26,7 @@
 #include "perfetto/ext/base/file_utils.h"
 #include "perfetto/ext/base/scoped_file.h"
 #include "perfetto/ext/base/string_utils.h"
+#include "src/trace_processor/shell/common_flags.h"
 #include "src/trace_processor/shell/subcommand.h"
 #include "src/traceconv/trace_to_bundle.h"
 
@@ -86,7 +87,11 @@
 
 Outputs a TAR containing the trace plus the symbols and deobfuscation
 mappings needed to make it self-contained. Both <input> and <output> must be
-real file paths (stdin/stdout are not supported).)";
+real file paths (stdin/stdout are not supported).
+
+Live progress uses stderr only when it is a terminal (except TERM=dumb).
+Nonempty FORCE_COLOR forces ANSI color, overriding NO_COLOR. Otherwise,
+nonempty NO_COLOR disables automatic color. Color does not enable progress.)";
 }
 
 std::vector<FlagSpec> BundleSubcommand::GetFlags() {
@@ -199,6 +204,7 @@
   context.no_auto_symbol_paths = no_auto_symbol_paths_;
   context.no_auto_proguard_maps = no_auto_proguard_maps_;
   context.verbose = verbose_;
+  context.no_progress = ctx.global && ctx.global->no_progress;
   if (const char* val = getenv("ANDROID_PRODUCT_OUT"))
     context.android_product_out = val;
   if (const char* val = getenv("HOME"))
diff --git a/src/trace_processor/shell/common_flags.cc b/src/trace_processor/shell/common_flags.cc
index fb554a2..f589a7a 100644
--- a/src/trace_processor/shell/common_flags.cc
+++ b/src/trace_processor/shell/common_flags.cc
@@ -15,7 +15,6 @@
  */
 
 #include "src/trace_processor/shell/common_flags.h"
-#include "src/traceconv/utils.h"
 
 #include <cstdio>
 #include <cstdlib>
@@ -33,6 +32,7 @@
 #include "perfetto/base/time.h"
 #include "perfetto/ext/base/file_utils.h"
 #include "perfetto/ext/base/getopt.h"
+#include "perfetto/ext/base/progress_reporter.h"
 #include "perfetto/ext/base/status_macros.h"
 #include "perfetto/ext/base/status_or.h"
 #include "perfetto/ext/base/string_splitter.h"
@@ -123,6 +123,10 @@
   flags.push_back(BoolFlag("help", 'h', "Prints this guide.", &opts->help));
   flags.push_back(
       BoolFlag("version", 'v', "Prints the version.", &opts->version));
+  flags.push_back(BoolFlag(
+      "no-progress", '\0',
+      "Disable live progress (summaries and warnings are still printed).",
+      &opts->no_progress));
   flags.push_back(BoolFlag("full-sort", '\0',
                            "Forces full sort ignoring windowing.",
                            &opts->force_full_sort));
@@ -432,18 +436,22 @@
 base::StatusOr<base::TimeNanos> LoadTraceFile(
     TraceProcessor* tp,
     TraceProcessorShell_PlatformInterface* platform,
-    const std::string& trace_file) {
+    const std::string& trace_file,
+    bool no_progress) {
   base::TimeNanos t_load_start = base::GetWallTimeNs();
   double size_mb = 0;
+  base::ProgressReporter progress(!no_progress);
 
-  base::Status load_status =
-      platform->LoadTrace(tp, trace_file, [&size_mb](size_t parsed_size) {
+  base::Status load_status = platform->LoadTrace(
+      tp, trace_file, [&size_mb, &progress](size_t parsed_size) {
         size_mb = static_cast<double>(parsed_size) / 1E6;
-        fprintf(stderr, "\rLoading trace: %.2f MB\r", size_mb);
+        progress.Update(
+            base::StackString<128>("Loading trace: %.2f MB", size_mb)
+                .ToStdString());
       });
   // Terminate the in-place progress line so errors/logs below start on a
   // fresh line.
-  trace_to_text::EndProgressLine();
+  progress.Clear();
   if (!load_status.ok()) {
     return base::ErrStatus("failed to read trace file (path: %s): %s",
                            trace_file.c_str(), load_status.c_message());
@@ -615,8 +623,9 @@
   }
   ASSIGN_OR_RETURN(Config config, BuildConfig(opts, platform));
   ASSIGN_OR_RETURN(auto tp, SetupTraceProcessor(opts, config, platform));
-  ASSIGN_OR_RETURN(base::TimeNanos t_load,
-                   LoadTraceFile(tp.get(), platform, trace_file));
+  ASSIGN_OR_RETURN(
+      base::TimeNanos t_load,
+      LoadTraceFile(tp.get(), platform, trace_file, opts.no_progress));
   if (t_load_out)
     *t_load_out = t_load;
   return std::move(tp);
diff --git a/src/trace_processor/shell/common_flags.h b/src/trace_processor/shell/common_flags.h
index 50a7076..51f8e4d 100644
--- a/src/trace_processor/shell/common_flags.h
+++ b/src/trace_processor/shell/common_flags.h
@@ -49,6 +49,7 @@
   // (unsupported yet) host:port.
   std::string remote_addr;
 
+  bool no_progress = false;
   bool force_full_sort = false;
   bool no_ftrace_raw = false;
   bool analyze_trace_proto_content = false;
@@ -113,7 +114,8 @@
 base::StatusOr<base::TimeNanos> LoadTraceFile(
     TraceProcessor* tp,
     TraceProcessorShell_PlatformInterface* platform,
-    const std::string& trace_file);
+    const std::string& trace_file,
+    bool no_progress = false);
 
 // Resolves the trace-file positional argument for a trace-consuming subcommand,
 // accounting for --remote. In --remote mode the trace is already loaded
diff --git a/src/trace_processor/shell/convert_subcommand.cc b/src/trace_processor/shell/convert_subcommand.cc
index 0247802..d7178c1 100644
--- a/src/trace_processor/shell/convert_subcommand.cc
+++ b/src/trace_processor/shell/convert_subcommand.cc
@@ -27,6 +27,7 @@
 #include "perfetto/base/status.h"
 #include "perfetto/ext/base/status_macros.h"
 #include "perfetto/ext/base/string_utils.h"
+#include "src/trace_processor/shell/common_flags.h"
 #include "src/trace_processor/shell/convert_helpers.h"
 #include "src/trace_processor/shell/subcommand.h"
 #include "src/traceconv/trace_to_firefox.h"
@@ -162,15 +163,19 @@
   RETURN_IF_ERROR(
       OpenConversionOutput(output_path, binary_output, &output_file, &output));
 
+  const bool no_progress = ctx.global && ctx.global->no_progress;
   if (format == "json") {
-    RETURN_IF_ERROR(trace_to_text::TraceToJson(
-        input, output, /*compress=*/false, truncate_keep, full_sort_));
+    RETURN_IF_ERROR(
+        trace_to_text::TraceToJson(input, output, /*compress=*/false,
+                                   truncate_keep, full_sort_, no_progress));
   } else if (format == "systrace") {
-    RETURN_IF_ERROR(trace_to_text::TraceToSystrace(
-        input, output, /*ctrace=*/false, truncate_keep, full_sort_));
+    RETURN_IF_ERROR(
+        trace_to_text::TraceToSystrace(input, output, /*ctrace=*/false,
+                                       truncate_keep, full_sort_, no_progress));
   } else if (format == "ctrace") {
-    RETURN_IF_ERROR(trace_to_text::TraceToSystrace(
-        input, output, /*ctrace=*/true, truncate_keep, full_sort_));
+    RETURN_IF_ERROR(
+        trace_to_text::TraceToSystrace(input, output, /*ctrace=*/true,
+                                       truncate_keep, full_sort_, no_progress));
   } else if (format == "text" || format == "profile" || format == "firefox") {
     if (truncate_keep != trace_to_text::Keep::kAll) {
       return base::ErrStatus("--truncate is unsupported for the '%s' format.",
@@ -183,6 +188,7 @@
     if (format == "text") {
       trace_to_text::TraceToTextOptions options;
       options.skip_unknown_fields = skip_unknown_;
+      options.no_progress = no_progress;
       RETURN_IF_ERROR(trace_to_text::TraceToText(input, output, options));
     } else if (format == "profile") {
       if (!output_path.empty()) {
@@ -192,9 +198,10 @@
       }
       RETURN_IF_ERROR(trace_to_text::TraceToProfile(
           input, pid, timestamps, !no_annotations_, output_dir_, profile_type,
-          verbose_));
+          verbose_, no_progress));
     } else {  // firefox
-      RETURN_IF_ERROR(trace_to_text::TraceToFirefoxProfile(input, output));
+      RETURN_IF_ERROR(
+          trace_to_text::TraceToFirefoxProfile(input, output, no_progress));
     }
   } else {
     return base::ErrStatus("convert: unknown format '%s'.", format.c_str());
diff --git a/src/trace_processor/shell/export_subcommand.cc b/src/trace_processor/shell/export_subcommand.cc
index 036c761..9bbe791 100644
--- a/src/trace_processor/shell/export_subcommand.cc
+++ b/src/trace_processor/shell/export_subcommand.cc
@@ -92,7 +92,9 @@
   ASSIGN_OR_RETURN(Config config, BuildConfig(*ctx.global, ctx.platform));
   ASSIGN_OR_RETURN(auto tp,
                    SetupTraceProcessor(*ctx.global, config, ctx.platform));
-  RETURN_IF_ERROR(LoadTraceFile(tp.get(), ctx.platform, trace_file).status());
+  RETURN_IF_ERROR(
+      LoadTraceFile(tp.get(), ctx.platform, trace_file, ctx.global->no_progress)
+          .status());
 
   TraceProcessor::ExportFormat export_format;
   if (format == "sqlite") {
diff --git a/src/trace_processor/shell/server_subcommand.cc b/src/trace_processor/shell/server_subcommand.cc
index 9a2697b..caad2aa 100644
--- a/src/trace_processor/shell/server_subcommand.cc
+++ b/src/trace_processor/shell/server_subcommand.cc
@@ -255,7 +255,8 @@
 
   if (!trace_file.empty()) {
     ASSIGN_OR_RETURN(auto t_load,
-                     LoadTraceFile(tp.get(), ctx.platform, trace_file));
+                     LoadTraceFile(tp.get(), ctx.platform, trace_file,
+                                   ctx.global->no_progress));
     base::ignore_result(t_load);
   }
 
diff --git a/src/trace_processor/shell/shell_utils.cc b/src/trace_processor/shell/shell_utils.cc
index d0b958e..d012b79 100644
--- a/src/trace_processor/shell/shell_utils.cc
+++ b/src/trace_processor/shell/shell_utils.cc
@@ -26,6 +26,7 @@
 #include "perfetto/base/logging.h"
 #include "perfetto/base/status.h"
 #include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/progress_reporter.h"
 #include "perfetto/ext/base/scoped_file.h"
 #include "perfetto/ext/base/status_macros.h"
 #include "perfetto/ext/base/string_utils.h"
@@ -66,14 +67,7 @@
 }  // namespace
 
 bool StderrSupportsColors() {
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) &&  \
-    !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM) && \
-    !PERFETTO_BUILDFLAG(PERFETTO_CHROMIUM_BUILD)
-  static const bool use_colors = isatty(STDERR_FILENO);
-  return use_colors;
-#else
-  return false;
-#endif
+  return base::StderrSupportsColor();
 }
 
 namespace {
diff --git a/src/trace_processor/shell/util_subcommand.cc b/src/trace_processor/shell/util_subcommand.cc
index c7aabf1..3ffb28e 100644
--- a/src/trace_processor/shell/util_subcommand.cc
+++ b/src/trace_processor/shell/util_subcommand.cc
@@ -35,6 +35,7 @@
 #include "perfetto/trace_processor/iterator.h"
 #include "perfetto/trace_processor/read_trace.h"
 #include "perfetto/trace_processor/trace_processor.h"
+#include "src/trace_processor/shell/common_flags.h"
 #include "src/trace_processor/shell/convert_helpers.h"
 #include "src/trace_processor/shell/subcommand.h"
 #include "src/trace_processor/util/json_value.h"
@@ -226,7 +227,8 @@
                                        &output_file, &output));
 
   if (util == "symbolize") {
-    RETURN_IF_ERROR(trace_to_text::SymbolizeProfile(input, output, verbose_));
+    RETURN_IF_ERROR(trace_to_text::SymbolizeProfile(
+        input, output, verbose_, ctx.global && ctx.global->no_progress));
   } else if (util == "deobfuscate") {
     RETURN_IF_ERROR(trace_to_text::DeobfuscateProfile(input, output));
   } else if (util == "decompress_packets") {
diff --git a/src/trace_processor/trace_processor_shell.cc b/src/trace_processor/trace_processor_shell.cc
index fcc8537..a11fc79 100644
--- a/src/trace_processor/trace_processor_shell.cc
+++ b/src/trace_processor/trace_processor_shell.cc
@@ -171,6 +171,7 @@
 Common flags (apply to all commands):
   -h, --help                  Show help (per-command if after a command).
   -v, --version               Print version.
+      --no-progress          Disable live progress; keep summaries and errors.
       --full-sort             Force full sort ignoring windowing.
       --no-ftrace-raw         Prevent ingestion of typed ftrace into raw table.
       --add-sql-package PATH  Register SQL files from a directory as a package.
diff --git a/src/traceconv/symbolize_profile.cc b/src/traceconv/symbolize_profile.cc
index b8503e9..3252263 100644
--- a/src/traceconv/symbolize_profile.cc
+++ b/src/traceconv/symbolize_profile.cc
@@ -33,7 +33,8 @@
 // be prepended to the profile to attach the symbol information.
 base::Status SymbolizeProfile(std::istream* input,
                               std::ostream* output,
-                              bool verbose) {
+                              bool verbose,
+                              bool no_progress) {
   profiling::SymbolizerConfig sym_config;
 
   const char* breakpad_dir = getenv("BREAKPAD_SYMBOL_DIR");
@@ -63,7 +64,7 @@
   std::unique_ptr<trace_processor::TraceProcessor> tp =
       trace_processor::TraceProcessor::CreateInstance(config);
 
-  if (!ReadTraceUnfinalized(tp.get(), input)) {
+  if (!ReadTraceUnfinalized(tp.get(), input, no_progress)) {
     return base::ErrStatus("failed to read trace");
   }
 
diff --git a/src/traceconv/symbolize_profile.h b/src/traceconv/symbolize_profile.h
index 466a6e3..69785de 100644
--- a/src/traceconv/symbolize_profile.h
+++ b/src/traceconv/symbolize_profile.h
@@ -26,7 +26,8 @@
 
 base::Status SymbolizeProfile(std::istream* input,
                               std::ostream* output,
-                              bool verbose);
+                              bool verbose,
+                              bool no_progress = false);
 
 }  // namespace trace_to_text
 }  // namespace perfetto
diff --git a/src/traceconv/trace_to_bundle.cc b/src/traceconv/trace_to_bundle.cc
index 6543e3c..54634d5 100644
--- a/src/traceconv/trace_to_bundle.cc
+++ b/src/traceconv/trace_to_bundle.cc
@@ -22,37 +22,34 @@
 #include "perfetto/base/build_config.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/base/status.h"
+#include "perfetto/ext/base/progress_reporter.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/trace_processor/read_trace.h"
 #include "perfetto/trace_processor/trace_processor.h"
 #include "src/trace_processor/util/tar_writer.h"
 #include "src/trace_processor/util/trace_enrichment/trace_enrichment.h"
 
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) &&  \
-    !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM) && \
-    !PERFETTO_BUILDFLAG(PERFETTO_CHROMIUM_BUILD)
-#include <unistd.h>  // For isatty()
-#endif
-
 namespace perfetto::trace_to_text {
 
 base::Status TraceToBundle(const std::string& input_file_path,
                            const std::string& output_file_path,
                            const BundleContext& context) {
+  base::ProgressReporter progress(!context.no_progress);
   auto tp = trace_processor::TraceProcessor::CreateInstance({});
 
-  // Report reading progress to stderr, like the interactive shell does, so
-  // long-running bundles don't look frozen.
   double loaded_mb = 0;
   auto status = trace_processor::ReadTrace(
-      tp.get(), input_file_path.c_str(), [&loaded_mb](uint64_t parsed_size) {
+      tp.get(), input_file_path.c_str(),
+      [&loaded_mb, &progress](uint64_t parsed_size) {
         loaded_mb = static_cast<double>(parsed_size) / 1E6;
-        fprintf(stderr, "\rReading trace: %.2f MB", loaded_mb);
+        progress.Update(
+            base::StackString<128>("Reading trace: %.2f MB", loaded_mb)
+                .ToStdString());
       });
-  if (!status.ok()) {
-    fprintf(stderr, "\n");
+  progress.Clear();
+  if (!status.ok())
     return base::ErrStatus("failed to read trace: %s", status.c_message());
-  }
-  fprintf(stderr, "\rRead trace: %.2f MB.\n", loaded_mb);
+  fprintf(stderr, "Read trace: %.2f MB.\n", loaded_mb);
 
   // Add original trace file directly (memory efficient). If the output path
   // cannot be opened, TarWriter fails gracefully and this propagates a
@@ -76,11 +73,7 @@
   enrich_config.home_dir = context.home_dir;
   enrich_config.working_dir = context.working_dir;
   enrich_config.root_dir = context.root_dir;
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WIN) &&  \
-    !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM) && \
-    !PERFETTO_BUILDFLAG(PERFETTO_CHROMIUM_BUILD)
-  enrich_config.colorize = isatty(STDERR_FILENO);
-#endif
+  enrich_config.colorize = base::StderrSupportsColor();
 
   // Add explicit ProGuard maps from context.
   for (const auto& map_spec : context.proguard_maps) {
diff --git a/src/traceconv/trace_to_bundle.h b/src/traceconv/trace_to_bundle.h
index f525341..979535a 100644
--- a/src/traceconv/trace_to_bundle.h
+++ b/src/traceconv/trace_to_bundle.h
@@ -47,6 +47,9 @@
   // If true, output verbose details (all paths tried, etc.)
   bool verbose = false;
 
+  // Suppress live progress; summaries and diagnostics are unaffected.
+  bool no_progress = false;
+
   // Value of ANDROID_PRODUCT_OUT for AOSP builds symbol discovery
   std::string android_product_out;
 
diff --git a/src/traceconv/trace_to_firefox.cc b/src/traceconv/trace_to_firefox.cc
index bc20b09..25c44c9 100644
--- a/src/traceconv/trace_to_firefox.cc
+++ b/src/traceconv/trace_to_firefox.cc
@@ -46,13 +46,13 @@
   PERFETTO_CHECK(it.Status().ok());
 }
 
-std::unique_ptr<trace_processor::TraceProcessor> LoadTrace(
-    std::istream* input) {
+std::unique_ptr<trace_processor::TraceProcessor> LoadTrace(std::istream* input,
+                                                           bool no_progress) {
   trace_processor::Config config;
   std::unique_ptr<trace_processor::TraceProcessor> tp =
       trace_processor::TraceProcessor::CreateInstance(config);
 
-  if (!ReadTraceUnfinalized(tp.get(), input)) {
+  if (!ReadTraceUnfinalized(tp.get(), input, no_progress)) {
     return nullptr;
   }
   if (auto status = tp->NotifyEndOfFile(); !status.ok()) {
@@ -63,8 +63,10 @@
 
 }  // namespace
 
-base::Status TraceToFirefoxProfile(std::istream* input, std::ostream* output) {
-  auto tp = LoadTrace(input);
+base::Status TraceToFirefoxProfile(std::istream* input,
+                                   std::ostream* output,
+                                   bool no_progress) {
+  auto tp = LoadTrace(input, no_progress);
   if (!tp) {
     return base::ErrStatus("failed to read trace");
   }
diff --git a/src/traceconv/trace_to_firefox.h b/src/traceconv/trace_to_firefox.h
index ac3d64d..9193df4 100644
--- a/src/traceconv/trace_to_firefox.h
+++ b/src/traceconv/trace_to_firefox.h
@@ -27,7 +27,9 @@
 // Exports trace as as Firefox Profile. More details here:
 // https://firefox-source-docs.mozilla.org/tools/profiler/code-overview.html
 // https://github.com/firefox-devtools/profiler/blob/main/src/types/profile.js
-base::Status TraceToFirefoxProfile(std::istream* input, std::ostream* output);
+base::Status TraceToFirefoxProfile(std::istream* input,
+                                   std::ostream* output,
+                                   bool no_progress = false);
 
 }  // namespace trace_to_text
 }  // namespace perfetto
diff --git a/src/traceconv/trace_to_json.cc b/src/traceconv/trace_to_json.cc
index a195b26..79c57e3 100644
--- a/src/traceconv/trace_to_json.cc
+++ b/src/traceconv/trace_to_json.cc
@@ -19,6 +19,8 @@
 #include <stdio.h>
 
 #include "perfetto/base/logging.h"
+#include "perfetto/ext/base/progress_reporter.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/ext/trace_processor/export_json.h"
 #include "perfetto/trace_processor/trace_processor.h"
 #include "src/traceconv/utils.h"
@@ -61,12 +63,14 @@
 };
 
 bool ExportUserspaceEvents(trace_processor::TraceProcessor* tp,
-                           TraceWriter* writer) {
-  ProgressLine("Converting userspace events");
+                           TraceWriter* writer,
+                           bool no_progress) {
+  base::ProgressReporter progress(!no_progress);
+  progress.Update("Converting userspace events");
 
   TraceWriterOutputWriter output(writer);
   base::Status status = trace_processor::json::ExportJson(tp, &output);
-  EndProgressLine();
+  progress.Clear();
   if (!status.ok()) {
     PERFETTO_ELOG("Could not convert userspace events: %s", status.c_message());
     return false;
@@ -84,7 +88,8 @@
                          std::ostream* output,
                          bool compress,
                          Keep truncate_keep,
-                         bool full_sort) {
+                         bool full_sort,
+                         bool no_progress) {
   std::unique_ptr<TraceWriter> trace_writer(
       compress ? new DeflateTraceWriter(output) : new TraceWriter(output));
 
@@ -95,14 +100,14 @@
   std::unique_ptr<trace_processor::TraceProcessor> tp =
       trace_processor::TraceProcessor::CreateInstance(config);
 
-  if (!ReadTraceUnfinalized(tp.get(), input))
+  if (!ReadTraceUnfinalized(tp.get(), input, no_progress))
     return base::ErrStatus("failed to read trace");
   if (auto status = tp->NotifyEndOfFile(); !status.ok()) {
     return base::ErrStatus("failed to finalize trace: %s", status.c_message());
   }
 
   // TODO(eseckler): Support truncation of userspace event data.
-  if (!ExportUserspaceEvents(tp.get(), trace_writer.get())) {
+  if (!ExportUserspaceEvents(tp.get(), trace_writer.get(), no_progress)) {
     // ExportJson streams directly to |trace_writer|, so emitting an empty
     // trace header here would corrupt any output already written. Report the
     // conversion failure instead of silently dropping userspace events.
@@ -111,15 +116,14 @@
   }
   trace_writer->Write(",\n");
 
-  int ret = ExtractSystrace(tp.get(), trace_writer.get(),
-                            /*wrapped_in_json=*/true, truncate_keep);
+  int ret =
+      ExtractSystrace(tp.get(), trace_writer.get(),
+                      /*wrapped_in_json=*/true, truncate_keep, no_progress);
   if (ret) {
-    EndProgressLine();
     return base::ErrStatus("failed to convert ftrace events");
   }
 
   trace_writer->Write(kTraceFooter);
-  EndProgressLine();
   return base::OkStatus();
 }
 
diff --git a/src/traceconv/trace_to_json.h b/src/traceconv/trace_to_json.h
index 1dc2d7a..f1cc219 100644
--- a/src/traceconv/trace_to_json.h
+++ b/src/traceconv/trace_to_json.h
@@ -29,7 +29,8 @@
                          std::ostream* output,
                          bool compress,
                          Keep truncate_keep,
-                         bool full_sort);
+                         bool full_sort,
+                         bool no_progress = false);
 
 }  // namespace trace_to_text
 }  // namespace perfetto
diff --git a/src/traceconv/trace_to_profile.cc b/src/traceconv/trace_to_profile.cc
index 9dcaac9..019e169 100644
--- a/src/traceconv/trace_to_profile.cc
+++ b/src/traceconv/trace_to_profile.cc
@@ -170,12 +170,13 @@
                             bool annotate_frames,
                             const std::string& output_dir,
                             std::optional<ConversionMode> explicit_mode,
-                            bool verbose) {
+                            bool verbose,
+                            bool no_progress) {
   // Pre-parse trace.
   trace_processor::Config config;
   std::unique_ptr<trace_processor::TraceProcessor> tp =
       trace_processor::TraceProcessor::CreateInstance(config);
-  if (!ReadTraceUnfinalized(tp.get(), input))
+  if (!ReadTraceUnfinalized(tp.get(), input, no_progress))
     return base::ErrStatus("failed to read trace");
   tp->Flush();
 
diff --git a/src/traceconv/trace_to_profile.h b/src/traceconv/trace_to_profile.h
index 4242386..e782085 100644
--- a/src/traceconv/trace_to_profile.h
+++ b/src/traceconv/trace_to_profile.h
@@ -35,7 +35,8 @@
                             bool annotate_frames,
                             const std::string& output_dir,
                             std::optional<ConversionMode> conversion_mode,
-                            bool verbose);
+                            bool verbose,
+                            bool no_progress = false);
 
 }  // namespace trace_to_text
 }  // namespace perfetto
diff --git a/src/traceconv/trace_to_systrace.cc b/src/traceconv/trace_to_systrace.cc
index 131dccc..fa3c8d8 100644
--- a/src/traceconv/trace_to_systrace.cc
+++ b/src/traceconv/trace_to_systrace.cc
@@ -28,6 +28,7 @@
 #include "perfetto/base/build_config.h"
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/dynamic_string_writer.h"
+#include "perfetto/ext/base/progress_reporter.h"
 #include "perfetto/ext/base/string_utils.h"
 #include "perfetto/trace_processor/trace_processor.h"
 #include "src/traceconv/utils.h"
@@ -112,8 +113,10 @@
 
 class QueryWriter {
  public:
-  QueryWriter(trace_processor::TraceProcessor* tp, TraceWriter* trace_writer)
-      : tp_(tp), trace_writer_(trace_writer) {}
+  QueryWriter(trace_processor::TraceProcessor* tp,
+              TraceWriter* trace_writer,
+              bool no_progress)
+      : tp_(tp), trace_writer_(trace_writer), progress_(!no_progress) {}
 
   template <typename Callback>
   bool RunQuery(const std::string& sql, Callback callback) {
@@ -124,14 +127,15 @@
       callback(&iterator, &line_writer);
 
       if (global_writer_.pos() + line_writer.pos() >= kFlushThreshold) {
-        ProgressLine("Writing row %" PRIu32, rows);
+        progress_.Update(
+            base::StackString<128>("Writing row %" PRIu32, rows).ToStdString());
         auto str = global_writer_.GetStringView();
         trace_writer_->Write(str.data(), str.size());
         global_writer_.Clear();
       }
       global_writer_.AppendStringView(line_writer.GetStringView());
     }
-    EndProgressLine();
+    progress_.Clear();
 
     // Check if we have an error in the iterator and print if so.
     auto status = iterator.Status();
@@ -153,12 +157,14 @@
   trace_processor::TraceProcessor* tp_ = nullptr;
   base::DynamicStringWriter global_writer_;
   TraceWriter* trace_writer_;
+  base::ProgressReporter progress_;
 };
 
 int ExtractRawEvents(TraceWriter* trace_writer,
                      QueryWriter& q_writer,
                      bool wrapped_in_json,
-                     Keep truncate_keep) {
+                     Keep truncate_keep,
+                     bool no_progress) {
   using trace_processor::Iterator;
 
   static const char kRawEventsCountSql[] = "select count(1) from ftrace_event";
@@ -178,7 +184,8 @@
     return 0;
   }
 
-  ProgressLine("Converting ftrace events");
+  base::ProgressReporter progress(!no_progress);
+  progress.Update("Converting ftrace events");
 
   auto raw_callback = [wrapped_in_json](Iterator* it,
                                         base::DynamicStringWriter* writer) {
@@ -267,7 +274,8 @@
                              std::ostream* output,
                              bool ctrace,
                              Keep truncate_keep,
-                             bool full_sort) {
+                             bool full_sort,
+                             bool no_progress) {
   std::unique_ptr<TraceWriter> trace_writer(
       ctrace ? new DeflateTraceWriter(output) : new TraceWriter(output));
 
@@ -278,7 +286,7 @@
   std::unique_ptr<trace_processor::TraceProcessor> tp =
       trace_processor::TraceProcessor::CreateInstance(config);
 
-  if (!ReadTraceUnfinalized(tp.get(), input))
+  if (!ReadTraceUnfinalized(tp.get(), input, no_progress))
     return base::ErrStatus("failed to read trace");
   if (auto status = tp->NotifyEndOfFile(); !status.ok()) {
     return base::ErrStatus("failed to finalize trace: %s", status.c_message());
@@ -287,23 +295,23 @@
   if (ctrace)
     *output << "TRACE:\n";
 
-  int ret = ExtractSystrace(tp.get(), trace_writer.get(),
-                            /*wrapped_in_json=*/false, truncate_keep);
+  int ret =
+      ExtractSystrace(tp.get(), trace_writer.get(),
+                      /*wrapped_in_json=*/false, truncate_keep, no_progress);
   if (ret) {
-    EndProgressLine();
     return base::ErrStatus("failed to convert ftrace events");
   }
-  EndProgressLine();
   return base::OkStatus();
 }
 
 int ExtractSystrace(trace_processor::TraceProcessor* tp,
                     TraceWriter* trace_writer,
                     bool wrapped_in_json,
-                    Keep truncate_keep) {
+                    Keep truncate_keep,
+                    bool no_progress) {
   using trace_processor::Iterator;
 
-  QueryWriter q_writer(tp, trace_writer);
+  QueryWriter q_writer(tp, trace_writer, no_progress);
   if (wrapped_in_json) {
     trace_writer->Write(kProcessDumpHeader);
 
@@ -344,7 +352,7 @@
     trace_writer->Write(kProcessDumpFooter);
   }
   return ExtractRawEvents(trace_writer, q_writer, wrapped_in_json,
-                          truncate_keep);
+                          truncate_keep, no_progress);
 }
 
 }  // namespace trace_to_text
diff --git a/src/traceconv/trace_to_systrace.h b/src/traceconv/trace_to_systrace.h
index c25a10b..3f934a3 100644
--- a/src/traceconv/trace_to_systrace.h
+++ b/src/traceconv/trace_to_systrace.h
@@ -37,12 +37,14 @@
                              std::ostream* output,
                              bool ctrace,
                              Keep truncate_keep,
-                             bool full_sort);
+                             bool full_sort,
+                             bool no_progress = false);
 
 int ExtractSystrace(trace_processor::TraceProcessor*,
                     TraceWriter*,
                     bool wrapped_in_json,
-                    Keep truncate_keep);
+                    Keep truncate_keep,
+                    bool no_progress = false);
 
 }  // namespace trace_to_text
 }  // namespace perfetto
diff --git a/src/traceconv/trace_to_text.cc b/src/traceconv/trace_to_text.cc
index eb22228..30ec97a 100644
--- a/src/traceconv/trace_to_text.cc
+++ b/src/traceconv/trace_to_text.cc
@@ -18,7 +18,9 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/progress_reporter.h"
 #include "perfetto/ext/base/scoped_file.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/ext/protozero/proto_ring_buffer.h"
 #include "src/traceconv/android_extension.descriptor.h"
 #include "src/traceconv/trace.descriptor.h"
@@ -50,8 +52,12 @@
 //    write the output in given std::ostream*.
 class OnlineTraceToText {
  public:
-  OnlineTraceToText(std::ostream* output, const TraceToTextOptions& options)
-      : output_(output), skip_unknown_fields_(options.skip_unknown_fields) {
+  OnlineTraceToText(std::ostream* output,
+                    const TraceToTextOptions& options,
+                    base::ProgressReporter* progress)
+      : output_(output),
+        skip_unknown_fields_(options.skip_unknown_fields),
+        progress_(progress) {
     pool_.AddFromFileDescriptorSet(kTraceDescriptor.data(),
                                    kTraceDescriptor.size());
     pool_.AddFromFileDescriptorSet(kAndroidExtensionDescriptor.data(),
@@ -84,6 +90,7 @@
   size_t bytes_processed_ = 0;
   size_t packet_ = 0;
   bool skip_unknown_fields_ = false;
+  base::ProgressReporter* progress_;
 };
 
 std::string OnlineTraceToText::TracePacketToText(protozero::ConstBytes packet,
@@ -153,7 +160,9 @@
     protos::pbzero::TracePacket::Decoder decoder(token.start, token.len);
     bytes_processed_ += token.len;
     if ((packet_++ & 0x3f) == 0) {
-      ProgressLine("Processing trace: %8zu KB", bytes_processed_ / 1024);
+      progress_->Update(base::StackString<128>("Processing trace: %8zu KB",
+                                               bytes_processed_ / 1024)
+                            .ToStdString());
     }
     if (decoder.has_compressed_packets()) {
       PrintCompressedPackets(decoder.compressed_packets(),
@@ -209,7 +218,8 @@
   uint32_t buffer_len = 0;
 
   InputReader input_reader(input);
-  OnlineTraceToText online_trace_to_text(output, options);
+  base::ProgressReporter progress(!options.no_progress);
+  OnlineTraceToText online_trace_to_text(output, options, &progress);
 
   // Sniff the first chunk inside the tokenizer's own buffer, so a proto trace
   // is never copied. Only a compressed one moves to the decompressor's input.
@@ -252,13 +262,13 @@
             online_trace_to_text.BeginWrite(kExtractSize), kExtractSize);
         if (res.ret == ResultCode::kError) {
           online_trace_to_text.AbortWrite();
-          EndProgressLine();
+          progress.Clear();
           return base::ErrStatus(
               "failed to decompress, trace is likely corrupt");
         }
         online_trace_to_text.EndWrite(res.bytes_written);
         if (!online_trace_to_text.ok()) {
-          EndProgressLine();
+          progress.Clear();
           return base::ErrStatus("failed to convert trace to text: %s",
                                  online_trace_to_text.error().c_str());
         }
@@ -281,33 +291,33 @@
              buffer_len > 0);
 
     if (code != ResultCode::kEof) {
-      EndProgressLine();
+      progress.Clear();
       return base::ErrStatus(
           "compressed stream incomplete, trace is likely corrupt");
     }
     if (!input_reader.ok()) {
-      EndProgressLine();
+      progress.Clear();
       return base::ErrStatus("failed to read trace: %s",
                              input_reader.error().c_str());
     }
-    EndProgressLine();
+    progress.Clear();
     return base::OkStatus();
   } else if (type == trace_processor::CompressedTraceType::kProto) {
     do {
       online_trace_to_text.EndWrite(buffer_len);
       if (!online_trace_to_text.ok()) {
-        EndProgressLine();
+        progress.Clear();
         return base::ErrStatus("failed to convert trace to text: %s",
                                online_trace_to_text.error().c_str());
       }
     } while (input_reader.Read(online_trace_to_text.BeginWrite(kReadSize),
                                &buffer_len, kReadSize));
     if (!input_reader.ok()) {
-      EndProgressLine();
+      progress.Clear();
       return base::ErrStatus("failed to read trace: %s",
                              input_reader.error().c_str());
     }
-    EndProgressLine();
+    progress.Clear();
     return base::OkStatus();
   } else {
     return base::ErrStatus(
diff --git a/src/traceconv/trace_to_text.h b/src/traceconv/trace_to_text.h
index ef8f7fe..56c792d 100644
--- a/src/traceconv/trace_to_text.h
+++ b/src/traceconv/trace_to_text.h
@@ -27,6 +27,7 @@
 struct TraceToTextOptions {
   // If true, unknown proto fields are skipped when converting to text.
   bool skip_unknown_fields = false;
+  bool no_progress = false;
 };
 
 // Returns OkStatus() on success.
diff --git a/src/traceconv/traceconv.cc b/src/traceconv/traceconv.cc
index e256883..8d502bc 100644
--- a/src/traceconv/traceconv.cc
+++ b/src/traceconv/traceconv.cc
@@ -120,6 +120,7 @@
                                       pkg= prefix scopes the map to a package.
    --no-auto-proguard-maps            Disable automatic ProGuard/R8 mapping
                                       discovery (e.g. Gradle project layout)
+   --no-progress                      Disable live progress
    --verbose                          Print more detailed output
 
  binary                               Converts text proto to binary format
@@ -181,6 +182,7 @@
   bool no_auto_symbol_paths = false;
   bool no_auto_proguard_maps = false;
   bool verbose = false;
+  bool no_progress = false;
   bool skip_unknown_fields = false;
   std::string output_dir;
   for (int i = 1; i < argc; i++) {
@@ -222,6 +224,8 @@
     } else if (i < argc && strcmp(argv[i], "--symbol-paths") == 0) {
       i++;
       symbol_paths = base::SplitString(argv[i], ",");
+    } else if (strcmp(argv[i], "--no-progress") == 0) {
+      no_progress = true;
     } else if (strcmp(argv[i], "--no-auto-symbol-paths") == 0) {
       no_auto_symbol_paths = true;
     } else if (strcmp(argv[i], "--no-auto-proguard-maps") == 0) {
@@ -330,19 +334,19 @@
   }
 
   if (format == "json")
-    return ToExitCode(trace_to_text::TraceToJson(input_stream, output_stream,
-                                                 /*compress=*/false,
-                                                 truncate_keep, full_sort));
+    return ToExitCode(trace_to_text::TraceToJson(
+        input_stream, output_stream,
+        /*compress=*/false, truncate_keep, full_sort, no_progress));
 
   if (format == "systrace")
     return ToExitCode(trace_to_text::TraceToSystrace(
-        input_stream, output_stream, /*ctrace=*/false, truncate_keep,
-        full_sort));
+        input_stream, output_stream, /*ctrace=*/false, truncate_keep, full_sort,
+        no_progress));
 
   if (format == "ctrace")
     return ToExitCode(trace_to_text::TraceToSystrace(
-        input_stream, output_stream, /*ctrace=*/true, truncate_keep,
-        full_sort));
+        input_stream, output_stream, /*ctrace=*/true, truncate_keep, full_sort,
+        no_progress));
 
   if (truncate_keep != trace_to_text::Keep::kAll) {
     PERFETTO_ELOG(
@@ -361,6 +365,7 @@
   if (format == "text") {
     trace_to_text::TraceToTextOptions options;
     options.skip_unknown_fields = skip_unknown_fields;
+    options.no_progress = no_progress;
     return ToExitCode(
         trace_to_text::TraceToText(input_stream, output_stream, options));
   }
@@ -374,27 +379,27 @@
     }
     return ToExitCode(trace_to_text::TraceToProfile(
         input_stream, pid, timestamps, !profile_no_annotations, output_dir,
-        profile_type, verbose));
+        profile_type, verbose, no_progress));
   }
 
   if (format == "java_heap_profile") {
     // legacy alias for "profile --java-heap"
     return ToExitCode(trace_to_text::TraceToProfile(
         input_stream, pid, timestamps, !profile_no_annotations, output_dir,
-        trace_to_text::ConversionMode::kJavaHeapProfile, verbose));
+        trace_to_text::ConversionMode::kJavaHeapProfile, verbose, no_progress));
   }
 
   if (format == "symbolize")
-    return ToExitCode(
-        trace_to_text::SymbolizeProfile(input_stream, output_stream, verbose));
+    return ToExitCode(trace_to_text::SymbolizeProfile(
+        input_stream, output_stream, verbose, no_progress));
 
   if (format == "deobfuscate")
     return ToExitCode(
         trace_to_text::DeobfuscateProfile(input_stream, output_stream));
 
   if (format == "firefox")
-    return ToExitCode(
-        trace_to_text::TraceToFirefoxProfile(input_stream, output_stream));
+    return ToExitCode(trace_to_text::TraceToFirefoxProfile(
+        input_stream, output_stream, no_progress));
 
   if (format == "decompress_packets")
     return ToExitCode(
@@ -434,6 +439,7 @@
     context.no_auto_symbol_paths = no_auto_symbol_paths;
     context.no_auto_proguard_maps = no_auto_proguard_maps;
     context.verbose = verbose;
+    context.no_progress = no_progress;
     if (const char* val = getenv("ANDROID_PRODUCT_OUT")) {
       context.android_product_out = val;
     }
diff --git a/src/traceconv/utils.cc b/src/traceconv/utils.cc
index 5be46c3..9b3d610 100644
--- a/src/traceconv/utils.cc
+++ b/src/traceconv/utils.cc
@@ -27,8 +27,10 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/ext/base/file_utils.h"
+#include "perfetto/ext/base/progress_reporter.h"
 #include "perfetto/ext/base/scoped_file.h"
 #include "perfetto/ext/base/string_splitter.h"
+#include "perfetto/ext/base/string_utils.h"
 #include "perfetto/protozero/scattered_heap_buffer.h"
 #include "perfetto/trace_processor/trace_processor.h"
 
@@ -40,7 +42,6 @@
 namespace perfetto {
 namespace trace_to_text {
 namespace {
-
 #if PERFETTO_BUILDFLAG(PERFETTO_ZLIB)
 constexpr size_t kCompressionBufferSize = 500 * 1024;
 #endif
@@ -48,7 +49,9 @@
 }  // namespace
 
 bool ReadTraceUnfinalized(trace_processor::TraceProcessor* tp,
-                          std::istream* input) {
+                          std::istream* input,
+                          bool no_progress) {
+  base::ProgressReporter progress(!no_progress);
   // 1MB chunk size seems the best tradeoff on a MacBook Pro 2013 - i7 2.8 GHz.
   constexpr size_t kChunkSize = 1024 * 1024;
 
@@ -63,8 +66,10 @@
 
   for (int i = 0;; i++) {
     if (i % kStderrRate == 0) {
-      ProgressLine("Loading trace %.2f MB",
-                   static_cast<double>(file_size) / 1.0e6);
+      progress.Update(
+          base::StackString<128>("Loading trace %.2f MB",
+                                 static_cast<double>(file_size) / 1.0e6)
+              .ToStdString());
     }
 
     std::unique_ptr<uint8_t[]> buf(new uint8_t[kChunkSize]);
@@ -81,8 +86,7 @@
     tp->Parse(std::move(buf), static_cast<size_t>(rsize));
   }
 
-  ProgressLine("Loaded trace");
-  EndProgressLine();
+  progress.Clear();
   return true;
 }
 
diff --git a/src/traceconv/utils.h b/src/traceconv/utils.h
index 64640e9..dd349ef 100644
--- a/src/traceconv/utils.h
+++ b/src/traceconv/utils.h
@@ -46,63 +46,9 @@
 
 namespace trace_to_text {
 
-// When running in Web Assembly, fflush() is a no-op and the stdio buffering
-// sends progress updates to JS only when a write ends with \n.
-#if PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
-constexpr char kProgressChar = '\n';
-#else
-constexpr char kProgressChar = '\r';
-#endif
-
-// True while an in-place progress line has been printed but not yet
-// terminated with a newline. TU-local: all progress printing and the
-// matching EndProgressLine() live in the same translation unit.
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
-namespace {
-inline bool& IsProgressLineActive() {
-  static bool active = false;
-  return active;
-}
-}  // namespace
-#endif
-
-// Prints an in-place progress update to stderr: the message followed by
-// kProgressChar (a '\r' so the next update overwrites it). Remembers that a
-// progress line is active so the matching EndProgressLine() terminates it
-// with a newline. No-op on WASM where updates already end with '\n'.
-inline void ProgressLine(const char* fmt, ...) PERFETTO_PRINTF_FORMAT(1, 2);
-inline void ProgressLine(const char* fmt, ...) {
-#if PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
-  ::perfetto::base::ignore_result(fmt);
-#else
-  va_list args;
-  va_start(args, fmt);
-  vfprintf(stderr, fmt, args);
-  va_end(args);
-  fputc(kProgressChar, stderr);
-  fflush(stderr);
-  IsProgressLineActive() = true;
-#endif
-}
-
-// Terminates the current in-place progress line so that subsequent output
-// (errors, logs, results) starts on a fresh line instead of overwriting or
-// merging with the progress text. No-op when no progress line is active, so
-// callers can invoke it unconditionally without producing stray blank lines.
-// On WASM, progress updates already end with a newline (see kProgressChar),
-// so this is a no-op there too.
-inline void EndProgressLine() {
-#if !PERFETTO_BUILDFLAG(PERFETTO_OS_WASM)
-  if (IsProgressLineActive()) {
-    fprintf(stderr, "\n");
-    fflush(stderr);
-    IsProgressLineActive() = false;
-  }
-#endif
-}
-
 bool ReadTraceUnfinalized(trace_processor::TraceProcessor* tp,
-                          std::istream* input);
+                          std::istream* input,
+                          bool no_progress = false);
 void IngestTraceOrDie(trace_processor::TraceProcessor* tp,
                       const std::string& trace_proto);