Skip to content

feat(terminal): Add an unconditional terminal colour policy - #2808

Merged
jviotti merged 2 commits into
sourcemeta:mainfrom
HarshPopat23:feat/terminal-color-always
Sep 14, 2026
Merged

jviotti merged 2 commits into
sourcemeta:mainfrom
HarshPopat23:feat/terminal-color-always

Conversation

@HarshPopat23

@HarshPopat23 HarshPopat23 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Captured output is noninteractive, but callers sometimes explicitly request ANSI styling. TerminalColorPolicy::Always supports that use case while preserving automatic detection (WhenInteractive) and disabled styling (Disabled). It also enables downstream CLI tests to assert ANSI output through ordinary capture (e.g. .clitest runners).

Changes

  • Added TerminalColorPolicy::Always to sourcemeta/core/terminal.h.
  • Updated terminal_color_enabled in terminal.cc to evaluate all three policies:
    • WhenInteractive: preserves destination-based terminal detection.
    • Always: unconditionally enables styling for all destinations (including pipes and redirected files).
    • Disabled: unconditionally suppresses styling.
  • Kept terminal_is_interactive truthful: Always does not alter terminal detection.
  • Updated public documentation and examples in terminal.h.
  • Extended the terminal test suite in terminal_test.cc:
    • Added RAII ScopedColorPolicy to isolate and restore policy state across test cases.
    • Added tests for Always, Disabled, and WhenInteractive policy behavior and transitions.
    • Added tests for destination-aware terminal_paint overloads (string and ostream) under Always and Disabled.
    • Added noninteractive child process tests verifying exact ANSI output under Always and plain text under WhenInteractive/Disabled when output is captured through pipes.

Review in cubic

@augmentcode

augmentcode Bot commented Sep 11, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR introduces an explicit always-on terminal color policy.

Changes:

  • Adds TerminalColorPolicy::Always to the public terminal API.
  • Updates color enablement to distinguish interactive, disabled, and unconditional policies.
  • Preserves terminal_is_interactive as a physical terminal-detection query.
  • Documents the behavior of redirected streams and policy retrieval.
  • Adds RAII policy restoration and policy-transition coverage to terminal tests.
  • Adds destination-aware paint tests for string and ostream overloads.
  • Adds subprocess capture coverage for noninteractive output.
  • Conditionally links process support and supplies the test binary path when available.
Technical note: Always emits ANSI sequences even when stdout/stderr are pipes or files.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread test/terminal/terminal_test.cc Outdated
sourcemeta::core::terminal_paint(
std::cout, sourcemeta::core::TerminalStream::Stdout, "streamed",
sourcemeta::core::TerminalStyle::Red);
std::cout << "\n"

@augmentcode augmentcode Bot Sep 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, the child process's stdout remains in text mode, so this newline is emitted as \r\n; spawn_and_capture preserves those bytes. Consequently the exact \n assertions in noninteractive_child_execution fail on Windows.

Severity: medium

Other Locations
  • test/terminal/terminal_test.cc:687
  • test/terminal/terminal_test.cc:704

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread test/terminal/terminal_test.cc Outdated
@HarshPopat23
HarshPopat23 force-pushed the feat/terminal-color-always branch from 3d17300 to 1308a1b Compare September 11, 2026 13:59
///
/// Determine whether styling is enabled for the specified stream.
///
/// Under `TerminalColorPolicy::WhenInteractive`, styling is enabled if the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel these doc additions are just re-explaining what you documented in the enum class? Can we remove them?

Comment thread test/terminal/CMakeLists.txt Outdated
target_link_libraries(sourcemeta_core_terminal_unit
PRIVATE sourcemeta::core::terminal)

if(SOURCEMETA_CORE_LANG_PROCESS)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make it conditional though, with the corresponding conditional preprocessor checks in the test file? We can just make it a required dependency of this to avoid the extra complexity?

Comment thread test/terminal/terminal_test.cc Outdated
#endif
}

auto main(int argc, char **argv) -> int {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't have a single test file doing both TEST() invocations AND a main entry point. As the sourcemeta/core/test.h runner IS providing its own entry point to execute the other ones.

I think a better design here is to split this test CLI into a NEW .cc file in test/terminal. Then this file can spawn it on its test logic, or you can hook it directly over CMake's add_test?

It also raises an interesting point: here you are trying to test an actual CLI for this. Which means: why can't we use .clitest in this module itself for this? Sounds like the perfect dog-fooding?

@HarshPopat23
HarshPopat23 force-pushed the feat/terminal-color-always branch from 1308a1b to 2499842 Compare September 11, 2026 15:38

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread test/terminal/CMakeLists.txt Outdated
Comment thread test/terminal/terminal_child_main.cc Outdated
@HarshPopat23
HarshPopat23 force-pushed the feat/terminal-color-always branch from 2499842 to afe62ca Compare September 11, 2026 16:49
@HarshPopat23

Copy link
Copy Markdown
Contributor Author

Thanks for the review @jviotti! I've updated the PR with all requested changes:

  1. Simplified docs: Removed the redundant paragraphs in terminal.h (terminal_color_enabled and TerminalColorPolicy), letting the enum definition document its own variants.
  2. Unconditional process dependency: Removed the if(SOURCEMETA_CORE_LANG_PROCESS) conditional and preprocessor guards, making sourcemeta::core::process a direct private test dependency in test/terminal/CMakeLists.txt.
  3. Dedicated child CLI helper: Extracted the child CLI into test/terminal/terminal_child_main.cc (sourcemeta_core_terminal_unit_child_main) and removed main() from terminal_test.cc, preserving sourcemeta_test's built-in test runner entry point.

All CI matrix checks (including macOS, Windows MSVC/MinGW, and AddressSanitizer) are passing green.

Comment thread test/terminal/CMakeLists.txt Outdated
PRIVATE sourcemeta::core::process)

sourcemeta_executable(NAMESPACE sourcemeta PROJECT core NAME terminal
VARIANT unit_child_main SOURCES terminal_child_main.cc)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
VARIANT unit_child_main SOURCES terminal_child_main.cc)
VARIANT test_helper SOURCES terminal_child_main.cc)

Nicer name?

Comment thread test/terminal/CMakeLists.txt

auto main(int argc, char **argv) -> int {
#if defined(_WIN32)
_setmode(_fileno(stdout), _O_BINARY);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this needed btw? Might be worth a comment explaining it?

Comment thread test/terminal/terminal_child_main.cc Outdated

if (argc > 1) {
const std::string_view mode{argv[1]};
if (mode == "--child-noninteractive-when-interactive") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least for encouraging re-use, it might be worth using src/lang/options to parse CLI options. That's the CLI option parser we have in this project

Comment thread test/terminal/terminal_test.cc Outdated

namespace {

class ScopedColorPolicy {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I think the fact we need a complex RAII guard for this makes me think whether we should have a proper _reset() function in src/core/terminal? Then the module, inside of itself, can always figure out how to fallback to the original policy?

Comment thread test/terminal/terminal_test.cc Outdated
EXPECT_EQ(stream_output.str(), "Streamed");
}

TEST(noninteractive_child_execution) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think replacing this test case with a couple of real .clitest files in test/terminal and registering them like that in test/terminal/CMakeLists.txt would be nicer

Add TerminalColorPolicy::Always to enable unconditional ANSI styling
regardless of destination interactivity. This allows callers to explicitly
request color output when streams are captured through pipes, such as in
automated CLI test runners, while keeping terminal detection truthful and
preserving WhenInteractive and Disabled semantics.

Provide terminal_reset_color_policy() to restore stream coloring to the
default policy, and exercise CLI color output across non-interactive
executions using .clitest suites with sourcemeta::core::options parsing.

Signed-off-by: HarshPopat23 <musichk61@gmail.com>
@HarshPopat23
HarshPopat23 force-pushed the feat/terminal-color-always branch from afe62ca to 357f18a Compare September 13, 2026 16:31

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/terminal/terminal.cc">

<violation number="1" location="src/core/terminal/terminal.cc:52">
P2: These reset functions hardcode `TerminalColorPolicy::WhenInteractive` and do not restore the policy that was active before the change, even though the review feedback that motivated them asked for a reset that lets the module "fall back to the original policy". Any caller that sets `Disabled` or `Always` and later invokes `terminal_reset_color_policy()` (expecting the usual restore-previous semantics of a "reset" API) silently gets `WhenInteractive` instead — for example re-enabling ANSI styling on an interactive terminal that the application explicitly disabled. Either track and restore the prior per-stream policy, or rename/document these as set-to-default so the contract is not misleading.</violation>
</file>

<file name="test/terminal/terminal_test.cc">

<violation number="1" location="test/terminal/terminal_test.cc:281">
P2: The ScopedColorPolicy guard was removed in favor of manual terminal_reset_color_policy() calls placed only at the end of each policy test. Because the test runner executes every TEST in this file sequentially in a single process, any test that aborts before its trailing reset (a debug assert, crash, or a future early return) leaks the global per-stream policy into all subsequent tests, producing cascade failures. The removed RAII guard restored state in its destructor unconditionally, so this is a real loss of test isolation. Reset the policy at the start of each policy test (several tests, including policy_reset_global, policy_always_enables_styling, policy_disabled_suppresses_styling and paint_destination_aware_*, only reset at the end), or keep a fixture/teardown that always restores.</violation>
</file>

<file name="test/terminal/terminal_child_main.cc">

<violation number="1" location="test/terminal/terminal_child_main.cc:50">
P3: The `values.empty()` branch can never run: `Options::parse` always pushes at least one value for a declared option, and throws `OptionsMissingOptionValueError` when `--color` has no value, so the intended "missing value for --color" message is unreachable and a bare `--color` aborts via an uncaught exception instead. Drop the dead branch, or wrap `application.parse(argc, argv)` in a try/catch over `sourcemeta::core::OptionsError` to emit a clean failure message.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

std::memory_order_relaxed);
}

auto terminal_reset_color_policy() noexcept -> void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These reset functions hardcode TerminalColorPolicy::WhenInteractive and do not restore the policy that was active before the change, even though the review feedback that motivated them asked for a reset that lets the module "fall back to the original policy". Any caller that sets Disabled or Always and later invokes terminal_reset_color_policy() (expecting the usual restore-previous semantics of a "reset" API) silently gets WhenInteractive instead — for example re-enabling ANSI styling on an interactive terminal that the application explicitly disabled. Either track and restore the prior per-stream policy, or rename/document these as set-to-default so the contract is not misleading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/terminal/terminal.cc, line 52:

<comment>These reset functions hardcode `TerminalColorPolicy::WhenInteractive` and do not restore the policy that was active before the change, even though the review feedback that motivated them asked for a reset that lets the module "fall back to the original policy". Any caller that sets `Disabled` or `Always` and later invokes `terminal_reset_color_policy()` (expecting the usual restore-previous semantics of a "reset" API) silently gets `WhenInteractive` instead — for example re-enabling ANSI styling on an interactive terminal that the application explicitly disabled. Either track and restore the prior per-stream policy, or rename/document these as set-to-default so the contract is not misleading.</comment>

<file context>
@@ -49,6 +49,14 @@ auto terminal_set_color_policy(TerminalStream stream,
                                               std::memory_order_relaxed);
 }
 
+auto terminal_reset_color_policy() noexcept -> void {
+  terminal_set_color_policy(TerminalColorPolicy::WhenInteractive);
+}
</file context>

}

TEST(policy_reset_per_stream) {
sourcemeta::core::terminal_set_color_policy(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The ScopedColorPolicy guard was removed in favor of manual terminal_reset_color_policy() calls placed only at the end of each policy test. Because the test runner executes every TEST in this file sequentially in a single process, any test that aborts before its trailing reset (a debug assert, crash, or a future early return) leaks the global per-stream policy into all subsequent tests, producing cascade failures. The removed RAII guard restored state in its destructor unconditionally, so this is a real loss of test isolation. Reset the policy at the start of each policy test (several tests, including policy_reset_global, policy_always_enables_styling, policy_disabled_suppresses_styling and paint_destination_aware_*, only reset at the end), or keep a fixture/teardown that always restores.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/terminal/terminal_test.cc, line 281:

<comment>The ScopedColorPolicy guard was removed in favor of manual terminal_reset_color_policy() calls placed only at the end of each policy test. Because the test runner executes every TEST in this file sequentially in a single process, any test that aborts before its trailing reset (a debug assert, crash, or a future early return) leaks the global per-stream policy into all subsequent tests, producing cascade failures. The removed RAII guard restored state in its destructor unconditionally, so this is a real loss of test isolation. Reset the policy at the start of each policy test (several tests, including policy_reset_global, policy_always_enables_styling, policy_disabled_suppresses_styling and paint_destination_aware_*, only reset at the end), or keep a fixture/teardown that always restores.</comment>

<file context>
@@ -311,11 +253,46 @@ TEST(policy_lifecycle_and_isolation) {
+}
+
+TEST(policy_reset_per_stream) {
+  sourcemeta::core::terminal_set_color_policy(
+      sourcemeta::core::TerminalStream::Stderr,
+      sourcemeta::core::TerminalColorPolicy::Disabled);
</file context>

auto policy{sourcemeta::core::TerminalColorPolicy::WhenInteractive};
if (application.contains("color")) {
const auto &values{application.at("color")};
if (values.empty()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The values.empty() branch can never run: Options::parse always pushes at least one value for a declared option, and throws OptionsMissingOptionValueError when --color has no value, so the intended "missing value for --color" message is unreachable and a bare --color aborts via an uncaught exception instead. Drop the dead branch, or wrap application.parse(argc, argv) in a try/catch over sourcemeta::core::OptionsError to emit a clean failure message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/terminal/terminal_child_main.cc, line 50:

<comment>The `values.empty()` branch can never run: `Options::parse` always pushes at least one value for a declared option, and throws `OptionsMissingOptionValueError` when `--color` has no value, so the intended "missing value for --color" message is unreachable and a bare `--color` aborts via an uncaught exception instead. Drop the dead branch, or wrap `application.parse(argc, argv)` in a try/catch over `sourcemeta::core::OptionsError` to emit a clean failure message.</comment>

<file context>
@@ -24,31 +26,44 @@ auto run_mode(sourcemeta::core::TerminalColorPolicy policy) -> int {
+  auto policy{sourcemeta::core::TerminalColorPolicy::WhenInteractive};
+  if (application.contains("color")) {
+    const auto &values{application.at("color")};
+    if (values.empty()) {
+      std::cerr << "missing value for --color\n";
+      return EXIT_FAILURE;
</file context>

@HarshPopat23

Copy link
Copy Markdown
Contributor Author

Thank you @jviotti for the review! I have addressed all feedback.
PTAL.

@jviotti jviotti left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonderful! Well done

@jviotti

jviotti commented Sep 14, 2026

Copy link
Copy Markdown
Member

@HarshPopat23 Though looks like there are conflicts!

Signed-off-by: HarshPopat23 <musichk61@gmail.com>
@HarshPopat23

Copy link
Copy Markdown
Contributor Author

PTAL : @jviotti

@jviotti
jviotti merged commit 8a53d99 into sourcemeta:main Sep 14, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants