diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a816eb..09e5ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,87 @@ All notable changes to this project will be documented in this file. --- +## [0.22.1] - 2026-01-03 + +### Added (0.22.1) + +- **Terminal Output Auto-Detection**: Automatic terminal capability detection and adaptive output formatting + - **Terminal Capability Detection**: New `TerminalCapabilities` dataclass and `detect_terminal_capabilities()` function in `src/specfact_cli/utils/terminal.py` + - **Terminal Mode Detection**: Three terminal modes (GRAPHICAL, BASIC, MINIMAL) automatically selected based on environment + - **Rich Console Configuration**: `get_configured_console()` function provides Rich Console instances configured for detected terminal capabilities + - **Progress Configuration**: `get_progress_config()` function provides appropriate Progress column configurations based on terminal mode + - **Environment Variable Support**: Respects standard environment variables (`NO_COLOR`, `FORCE_COLOR`, `CI`, `TEST_MODE`, `PYTEST_CURRENT_TEST`) + - **CI/CD Detection**: Automatically detects CI/CD environments (GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins, Buildkite) and uses BASIC mode + - **Embedded Terminal Support**: Automatically detects embedded terminals (Cursor, VS Code) and adapts output for optimal readability + - **TTY Detection**: Uses `sys.stdout.isatty()` to determine interactive vs non-interactive terminals + - **Plain Text Progress**: `print_progress()` helper function for plain text progress updates in BASIC/MINIMAL modes + - **Cached Console Instances**: Console instances are cached for performance (lazy initialization pattern) + +- **Terminal Output Testing Guide**: New comprehensive testing guide `docs/guides/testing-terminal-output.md` + - **Multiple Testing Methods**: Instructions for testing with `NO_COLOR`, `CI=true`, `TERM=dumb`, and other methods + - **GNOME Terminal Instructions**: Specific instructions for Ubuntu/GNOME systems + - **Verification Commands**: Commands to verify terminal mode detection and capabilities + - **Expected Behavior**: Clear documentation of what to expect in each terminal mode + +### Changed (0.22.1) + +- **CLI Commands Terminal Output**: All CLI commands now use adaptive terminal output + - **Import Command**: `specfact import` uses `get_configured_console()` and `get_progress_config()` for adaptive progress display + - **Sync Command**: `specfact sync` uses adaptive terminal output for all progress indicators + - **Generate Command**: `specfact generate` uses configured console for consistent output + - **SDD Command**: `specfact sdd` uses adaptive terminal output + - **Bridge Sync**: Internal `BridgeSync` class uses adaptive terminal output for progress indicators + - **Progress Utilities**: `load_bundle_with_progress()` and `save_bundle_with_progress()` use lazy imports to avoid circular dependencies + +- **Runtime Terminal Management**: Enhanced `src/specfact_cli/runtime.py` with terminal mode management + - **TerminalMode Enum**: New enum with GRAPHICAL, BASIC, and MINIMAL values + - **get_terminal_mode()**: Function to determine current terminal mode based on capabilities + - **get_configured_console()**: Central function to get cached, configured Rich Console instance + - **Integration**: All terminal detection logic integrated into runtime module + +- **Documentation Updates**: Comprehensive documentation updates for terminal output behavior + - **Troubleshooting Guide**: Added detailed "Terminal Output Issues" section in `docs/guides/troubleshooting.md` + - Auto-detection explanation (detection order and logic) + - Terminal modes documentation (GRAPHICAL, BASIC, MINIMAL) + - Environment variable overrides + - Examples and troubleshooting for embedded terminals and CI/CD + - **UX Features Guide**: Updated "Unified Progress Display" section in `docs/guides/ux-features.md` + - Added "Automatic Terminal Adaptation" subsection + - Explains auto-detection for different terminal types + - Links to troubleshooting guide + - **IDE Integration Guide**: Added terminal output note in `docs/guides/ide-integration.md` + - Mentions automatic detection for embedded terminals + - Links to troubleshooting guide + - **Use Cases Guide**: Added terminal output note in CI/CD use case section + - Explains plain text output in CI/CD environments + - Links to troubleshooting guide + +### Fixed (0.22.1) + +- **Circular Import Resolution**: Fixed circular dependency between `progress.py` and `runtime.py` using lazy imports +- **Progress API Usage**: Fixed `Progress` initialization to use positional arguments for columns (not `columns` keyword) +- **Console Configuration**: Fixed console configuration to properly respect terminal capabilities +- **Test Environment**: Fixed test environment setup to properly simulate different terminal modes + +### Documentation (0.22.1) + +- **Terminal Output Documentation**: Comprehensive documentation for terminal output auto-detection + - **Troubleshooting Section**: Complete terminal output troubleshooting guide with auto-detection details + - **Testing Guide**: New guide for testing terminal output modes on different systems + - **UX Features**: Updated progress display documentation with terminal adaptation details + - **IDE Integration**: Added terminal output information for embedded terminals + - **Use Cases**: Added CI/CD terminal output behavior documentation + +### Notes (0.22.1) + +- **Zero Configuration**: Terminal output auto-detection requires no manual configuration - works out of the box +- **Backward Compatible**: All existing Rich features continue to work - auto-detection only enhances compatibility +- **Standard Compliance**: Respects `NO_COLOR` standard () for color disabling +- **CI/CD Optimized**: Automatically uses plain text output in CI/CD for better log readability +- **Test Mode Support**: Automatically uses minimal output when `TEST_MODE=true` or `PYTEST_CURRENT_TEST` is set + +--- + ## [0.22.0] - 2026-01-01 ### Breaking Changes (0.22.0) diff --git a/docs/guides/ide-integration.md b/docs/guides/ide-integration.md index 360f529..f9d8d2f 100644 --- a/docs/guides/ide-integration.md +++ b/docs/guides/ide-integration.md @@ -11,6 +11,8 @@ permalink: /ide-integration/ **CLI-First Approach**: SpecFact works offline, requires no account, and integrates with your existing workflow. Works with VS Code, Cursor, GitHub Actions, pre-commit hooks, or any IDE. No platform to learn, no vendor lock-in. +**Terminal Output**: The CLI automatically detects embedded terminals (Cursor, VS Code) and CI/CD environments, adapting output formatting automatically. Progress indicators work in all environments - see [Troubleshooting](troubleshooting.md#terminal-output-issues) for details. + --- ## Overview @@ -320,6 +322,7 @@ The `specfact init` command handles all conversions automatically. - [Command Reference](../reference/commands.md) - All CLI commands - [CoPilot Mode Guide](copilot-mode.md) - Using `--mode copilot` on CLI - [Getting Started](../getting-started/installation.md) - Installation and setup +- [Troubleshooting](troubleshooting.md#terminal-output-issues) - Terminal output auto-detection in embedded terminals --- diff --git a/docs/guides/testing-terminal-output.md b/docs/guides/testing-terminal-output.md new file mode 100644 index 0000000..e1cdbca --- /dev/null +++ b/docs/guides/testing-terminal-output.md @@ -0,0 +1,216 @@ +--- +layout: default +title: Testing Terminal Output Modes +permalink: /testing-terminal-output/ +--- + +# Testing Terminal Output Modes + +This guide explains how to test SpecFact CLI's terminal output auto-detection on Ubuntu/GNOME systems. + +## Quick Test Methods + +### Method 1: Use NO_COLOR (Easiest) + +The `NO_COLOR` environment variable is the standard way to disable colors: + +```bash +# Test in current terminal session +NO_COLOR=1 specfact --help + +# Or export for the entire session +export NO_COLOR=1 +specfact import from-code my-bundle +unset NO_COLOR # Re-enable colors +``` + +### Method 2: Simulate CI/CD Environment + +Simulate a CI/CD pipeline (BASIC mode): + +```bash +# Set CI environment variable +CI=true specfact --help + +# Or simulate GitHub Actions +GITHUB_ACTIONS=true specfact import from-code my-bundle +``` + +### Method 3: Use Dumb Terminal Type + +Force a "dumb" terminal that doesn't support colors: + +```bash +# Start a terminal with dumb TERM +TERM=dumb specfact --help + +# Or use vt100 (minimal terminal) +TERM=vt100 specfact --help +``` + +### Method 4: Redirect to Non-TTY + +Redirect output to a file or pipe (non-interactive): + +```bash +# Redirect to file (non-TTY) +specfact --help > output.txt 2>&1 +cat output.txt + +# Pipe to another command (non-TTY) +specfact --help | cat +``` + +### Method 5: Use script Command + +The `script` command can create a non-interactive session: + +```bash +# Create a script session (records to typescript file) +script -c "specfact --help" output.txt + +# Or use script with dumb terminal +TERM=dumb script -c "specfact --help" output.txt +``` + +## Testing in GNOME Terminal + +### Option A: Launch Terminal with NO_COLOR + +```bash +# Launch gnome-terminal with NO_COLOR set +gnome-terminal -- bash -c "export NO_COLOR=1; specfact --help; exec bash" +``` + +### Option B: Create a Test Script + +Create a test script `test-no-color.sh`: + +```bash +#!/bin/bash +export NO_COLOR=1 +specfact --help +``` + +Then run: + +```bash +chmod +x test-no-color.sh +./test-no-color.sh +``` + +### Option C: Use Different Terminal Emulators + +Install and test with different terminal emulators: + +```bash +# Install alternative terminals +sudo apt install xterm terminator + +# Test with xterm (can be configured for minimal support) +xterm -e "NO_COLOR=1 specfact --help" + +# Test with terminator +terminator -e "NO_COLOR=1 specfact --help" +``` + +## Verifying Terminal Mode Detection + +You can verify which mode is detected: + +```bash +# Check detected terminal mode +python3 -c "from specfact_cli.runtime import get_terminal_mode; print(get_terminal_mode())" + +# Check terminal capabilities +python3 -c " +from specfact_cli.utils.terminal import detect_terminal_capabilities +caps = detect_terminal_capabilities() +print(f'Color: {caps.supports_color}') +print(f'Animations: {caps.supports_animations}') +print(f'Interactive: {caps.is_interactive}') +print(f'CI: {caps.is_ci}') +" +``` + +## Expected Behavior + +### GRAPHICAL Mode (Default in Full Terminal) + +- ✅ Colors enabled +- ✅ Animations enabled +- ✅ Full progress bars +- ✅ Rich formatting + +### BASIC Mode (NO_COLOR or CI/CD) + +- ❌ No colors +- ❌ No animations +- ✅ Plain text progress updates +- ✅ Readable output + +### MINIMAL Mode (TEST_MODE) + +- ❌ No colors +- ❌ No animations +- ❌ Minimal output +- ✅ Test-friendly + +## Complete Test Workflow + +```bash +# 1. Test with colors (default) +specfact --help + +# 2. Test without colors (NO_COLOR) +NO_COLOR=1 specfact --help + +# 3. Test CI/CD mode +CI=true specfact --help + +# 4. Test minimal mode +TEST_MODE=true specfact --help + +# 5. Verify detection +python3 -c "from specfact_cli.runtime import get_terminal_mode; print(get_terminal_mode())" +``` + +## Troubleshooting + +If terminal detection isn't working as expected: + +1. **Check environment variables**: + + ```bash + echo "NO_COLOR: $NO_COLOR" + echo "FORCE_COLOR: $FORCE_COLOR" + echo "TERM: $TERM" + echo "CI: $CI" + ``` + +2. **Verify TTY status**: + + ```bash + python3 -c "import sys; print('Is TTY:', sys.stdout.isatty())" + ``` + +3. **Check terminal capabilities**: + + ```bash + python3 -c " + from specfact_cli.utils.terminal import detect_terminal_capabilities + import json + caps = detect_terminal_capabilities() + print(json.dumps({ + 'supports_color': caps.supports_color, + 'supports_animations': caps.supports_animations, + 'is_interactive': caps.is_interactive, + 'is_ci': caps.is_ci + }, indent=2)) + " + ``` + +## Related Documentation + +- [Troubleshooting](troubleshooting.md#terminal-output-issues) - Terminal output issues and auto-detection +- [UX Features](ux-features.md) - User experience features including terminal output diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index a885e3f..a70936a 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -540,6 +540,111 @@ specfact plan select --last 5 --- +## Terminal Output Issues + +SpecFact CLI **automatically detects terminal capabilities** and adjusts output formatting for optimal user experience across different environments. No manual configuration required - the CLI adapts to your terminal environment. + +### How Terminal Auto-Detection Works + +The CLI automatically detects terminal capabilities in this order: + +1. **Test Mode Detection**: + - `TEST_MODE=true` or `PYTEST_CURRENT_TEST` → **MINIMAL** mode + +2. **CI/CD Detection**: + - `CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, `TRAVIS`, `JENKINS_URL`, `BUILDKITE` → **BASIC** mode + +3. **Color Support Detection**: + - `NO_COLOR` → Disables colors (respects [NO_COLOR standard](https://no-color.org/)) + - `FORCE_COLOR=1` → Forces colors + - `TERM` and `COLORTERM` environment variables → Additional hints + +4. **Terminal Type Detection**: + - TTY detection (`sys.stdout.isatty()`) → Interactive vs non-interactive + - Interactive TTY with animations → **GRAPHICAL** mode + - Non-interactive → **BASIC** mode + +5. **Default Fallback**: + - If uncertain → **BASIC** mode (safe, readable output) + +### Terminal Modes + +The CLI supports three terminal modes (auto-selected based on detection): + +* **GRAPHICAL** - Full Rich features (colors, animations, progress bars) for interactive terminals +* **BASIC** - Plain text, no animations, simple progress updates for CI/CD and embedded terminals +* **MINIMAL** - Minimal output for test mode + +### Environment Variables (Optional Overrides) + +You can override auto-detection using standard environment variables: + +* **`NO_COLOR`** - Disables all colors (respects [NO_COLOR standard](https://no-color.org/)) +* **`FORCE_COLOR=1`** - Forces color output even in non-interactive terminals +* **`CI=true`** - Explicitly enables basic mode (no animations, plain text) +* **`TEST_MODE=true`** - Enables minimal mode for testing + +### Examples + +```bash +# Auto-detection (default behavior) +specfact import from-code my-bundle +# → Automatically detects terminal and uses appropriate mode + +# Manual override: Disable colors +NO_COLOR=1 specfact import from-code my-bundle + +# Manual override: Force colors in CI/CD +FORCE_COLOR=1 specfact sync bridge + +# Manual override: Explicit CI/CD mode +CI=true specfact import from-code my-bundle +``` + +### No Progress Visible in Embedded Terminals + +**Issue**: No progress indicators visible when running commands in Cursor, VS Code, or other embedded terminals. + +**Cause**: Embedded terminals are non-interactive and may not support Rich animations. + +**Solution**: The CLI automatically detects embedded terminals and switches to basic mode with plain text progress updates. If you still don't see progress: + +1. **Verify auto-detection is working**: + ```bash + # Check terminal mode (should show BASIC in embedded terminals) + python -c "from specfact_cli.runtime import get_terminal_mode; print(get_terminal_mode())" + ``` + +2. **Check environment variables**: + ```bash + # Ensure NO_COLOR is not set (unless you want plain text) + unset NO_COLOR + ``` + +3. **Verify terminal supports stdout**: + - Embedded terminals should support stdout (not stderr-only) + - Progress updates are throttled - wait a few seconds for updates + +4. **Manual override** (if needed): + ```bash + # Force basic mode + CI=true specfact import from-code my-bundle + ``` + +### Colors Not Working in CI/CD + +**Issue**: No colors in CI/CD pipeline output. + +**Cause**: CI/CD environments are automatically detected and use basic mode (no colors) for better log readability. + +**Solution**: This is expected behavior. CI/CD logs are more readable without colors. To force colors: + +```bash +FORCE_COLOR=1 specfact import from-code my-bundle +``` + +--- + ## Getting Help If you're still experiencing issues: diff --git a/docs/guides/use-cases.md b/docs/guides/use-cases.md index 14f0c96..8098b7d 100644 --- a/docs/guides/use-cases.md +++ b/docs/guides/use-cases.md @@ -469,6 +469,8 @@ specfact repro --budget 120 --verbose **Solution**: Add SpecFact GitHub Action to PR workflow. +**Terminal Output**: The CLI automatically detects CI/CD environments and uses plain text output (no colors, no animations) for better log readability. Progress updates are visible in CI/CD logs. See [Troubleshooting](troubleshooting.md#terminal-output-issues) for details. + ### Steps (CI/CD Integration) #### 1. Add GitHub Action diff --git a/docs/guides/ux-features.md b/docs/guides/ux-features.md index 1af83b9..c3c723c 100644 --- a/docs/guides/ux-features.md +++ b/docs/guides/ux-features.md @@ -242,7 +242,7 @@ Watch mode uses an optimized cache: ## Unified Progress Display -All commands use consistent progress indicators. +All commands use consistent progress indicators that automatically adapt to your terminal environment. ### Progress Format @@ -259,6 +259,17 @@ This shows: - Current artifact name (FEATURE-001.yaml) - Elapsed time +### Automatic Terminal Adaptation + +The CLI **automatically detects terminal capabilities** and adjusts progress display: + +- **Interactive terminals** → Full Rich progress with animations, colors, and progress bars +- **Embedded terminals** (Cursor, VS Code) → Plain text progress updates (no animations) +- **CI/CD pipelines** → Plain text progress updates for readable logs +- **Test mode** → Minimal output + +**No manual configuration required** - the CLI adapts automatically. See [Troubleshooting](troubleshooting.md#terminal-output-issues) for details. + ### Visibility Progress is shown for: @@ -268,7 +279,7 @@ Progress is shown for: - File processing operations - Analysis operations -**No "dark" periods** - you always know what's happening. +**No "dark" periods** - you always know what's happening, regardless of terminal type. ## Best Practices @@ -303,3 +314,4 @@ Progress is shown for: - [Command Reference](../reference/commands.md) - Complete command documentation - [Workflows](workflows.md) - Common daily workflows - [IDE Integration](ide-integration.md) - Enhanced IDE experience +- [Troubleshooting](troubleshooting.md#terminal-output-issues) - Terminal output auto-detection and troubleshooting diff --git a/pyproject.toml b/pyproject.toml index fd861e5..b093027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.22.0" +version = "0.22.1" description = "Brownfield-first CLI: Reverse engineer legacy Python → specs → enforced contracts. Automate legacy code documentation and prevent modernization regressions." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index f1e1551..b89997c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.22.0", + version="0.22.1", description="SpecFact CLI - Spec→Contract→Sentinel tool for contract-driven development", packages=find_packages(where="src"), package_dir={"": "src"}, diff --git a/src/__init__.py b/src/__init__.py index abf2254..73ce42b 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Define the package version (kept in sync with pyproject.toml and setup.py) -__version__ = "0.22.0" +__version__ = "0.22.1" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 814bc42..1da476b 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -9,6 +9,6 @@ - Validating reproducibility """ -__version__ = "0.22.0" +__version__ = "0.22.1" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index e09df08..11d7ac8 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -48,7 +48,6 @@ def _normalized_detect_shell(pid=None, max_depth=10): # type: ignore[misc] import typer from beartype import beartype from icontract import ViolationError -from rich.console import Console from rich.panel import Panel from specfact_cli import __version__, runtime @@ -71,6 +70,7 @@ def _normalized_detect_shell(pid=None, max_depth=10): # type: ignore[misc] sync, ) from specfact_cli.modes import OperationalMode, detect_mode +from specfact_cli.runtime import get_configured_console from specfact_cli.utils.progressive_disclosure import ProgressiveDisclosureGroup from specfact_cli.utils.structured_io import StructuredFormat @@ -124,7 +124,7 @@ def normalize_shell_in_argv() -> None: cls=ProgressiveDisclosureGroup, # Use custom group for progressive disclosure ) -console = Console() +console = get_configured_console() # Global mode context (set by --mode flag or auto-detected) _current_mode: OperationalMode | None = None diff --git a/src/specfact_cli/commands/generate.py b/src/specfact_cli/commands/generate.py index ab9cbd9..f0b9361 100644 --- a/src/specfact_cli/commands/generate.py +++ b/src/specfact_cli/commands/generate.py @@ -11,11 +11,11 @@ import typer from beartype import beartype from icontract import ensure, require -from rich.console import Console from specfact_cli.generators.contract_generator import ContractGenerator from specfact_cli.migrations.plan_migrator import load_plan_bundle from specfact_cli.models.sdd import SDDManifest +from specfact_cli.runtime import get_configured_console from specfact_cli.telemetry import telemetry from specfact_cli.utils import print_error, print_info, print_success, print_warning from specfact_cli.utils.env_manager import ( @@ -29,7 +29,7 @@ app = typer.Typer(help="Generate artifacts from SDD and plans") -console = Console() +console = get_configured_console() def _show_apply_help() -> None: diff --git a/src/specfact_cli/commands/import_cmd.py b/src/specfact_cli/commands/import_cmd.py index 7c21f0b..7a61635 100644 --- a/src/specfact_cli/commands/import_cmd.py +++ b/src/specfact_cli/commands/import_cmd.py @@ -16,23 +16,24 @@ import typer from beartype import beartype from icontract import require -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.progress import Progress from specfact_cli import runtime from specfact_cli.adapters.registry import AdapterRegistry from specfact_cli.models.plan import Feature, PlanBundle from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.runtime import get_configured_console from specfact_cli.telemetry import telemetry from specfact_cli.utils.performance import track_performance from specfact_cli.utils.progress import save_bundle_with_progress +from specfact_cli.utils.terminal import get_progress_config app = typer.Typer( help="Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, Linear, Jira) to contract format", context_settings={"help_option_names": ["-h", "--help", "--help-advanced", "-ha"]}, ) -console = Console() +console = get_configured_console() def _is_valid_repo_path(path: Path) -> bool: @@ -98,11 +99,11 @@ def _check_incremental_changes( from specfact_cli.utils.incremental_check import check_incremental_changes try: + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: task = progress.add_task("[cyan]Checking for changes...", total=None) progress.update(task, description="[cyan]Loading manifest and checking file changes...") @@ -576,7 +577,7 @@ def load_contract(feature: Feature) -> tuple[str, dict[str, Any] | None]: f"[cyan]📋 Extracting contracts from {len(features_with_files)} features (using {max_workers} workers)...[/cyan]" ) - from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + from rich.progress import Progress def process_feature(feature: Feature) -> tuple[str, dict[str, Any] | None]: """Process a single feature and return (feature_key, openapi_spec or None).""" @@ -611,14 +612,11 @@ def process_feature(feature: Feature) -> tuple[str, dict[str, Any] | None]: pass return (feature.key, None) + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - TextColumn("({task.completed}/{task.total})"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: task = progress.add_task("[cyan]Extracting contracts...", total=len(features_with_files)) if is_test_mode: @@ -1313,11 +1311,11 @@ def from_bridge( # Ensure SpecFact structure exists SpecFactStructure.ensure_structure(repo) + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: # Step 1: Discover features from markdown artifacts (adapter-agnostic) task = progress.add_task(f"Discovering {adapter_lower} features...", total=None) diff --git a/src/specfact_cli/commands/sdd.py b/src/specfact_cli/commands/sdd.py index 1b8e43a..7429b4b 100644 --- a/src/specfact_cli/commands/sdd.py +++ b/src/specfact_cli/commands/sdd.py @@ -13,10 +13,10 @@ import typer from beartype import beartype from icontract import ensure, require -from rich.console import Console from rich.table import Table from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher +from specfact_cli.runtime import get_configured_console from specfact_cli.utils import print_error, print_info, print_success from specfact_cli.utils.sdd_discovery import list_all_sdds from specfact_cli.utils.structure import SpecFactStructure @@ -28,7 +28,7 @@ rich_markup_mode="rich", ) -console = Console() +console = get_configured_console() # Constitution subcommand group constitution_app = typer.Typer( diff --git a/src/specfact_cli/commands/sync.py b/src/specfact_cli/commands/sync.py index 07f700b..24f9ac4 100644 --- a/src/specfact_cli/commands/sync.py +++ b/src/specfact_cli/commands/sync.py @@ -16,20 +16,21 @@ import typer from beartype import beartype from icontract import ensure, require -from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn from specfact_cli import runtime from specfact_cli.adapters.registry import AdapterRegistry from specfact_cli.models.bridge import AdapterType from specfact_cli.models.plan import Feature, PlanBundle +from specfact_cli.runtime import get_configured_console from specfact_cli.telemetry import telemetry +from specfact_cli.utils.terminal import get_progress_config app = typer.Typer( help="Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, Linear, Jira, etc.)" ) -console = Console() +console = get_configured_console() @beartype @@ -177,11 +178,11 @@ def _perform_sync_operation( # Note: _sync_tool_to_specfact now uses adapter pattern, so converter/scanner are no longer needed + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: # Step 3: Discover features using adapter (via bridge config) task = progress.add_task(f"[cyan]Scanning {adapter_type.value} artifacts...[/cyan]", total=None) @@ -1222,11 +1223,11 @@ def sync_bridge( bridge_sync = BridgeSync(repo, bridge_config=bridge_config) # Export change proposals + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: task = progress.add_task("[cyan]Syncing change proposals to DevOps...[/cyan]", total=None) @@ -1291,11 +1292,11 @@ def sync_bridge( bridge_sync = BridgeSync(repo, bridge_config=bridge_config) # Import OpenSpec artifacts + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: task = progress.add_task("[cyan]Importing OpenSpec artifacts...[/cyan]", total=None) @@ -1869,11 +1870,10 @@ def sync_intelligent( specfact sync intelligent my-bundle --repo . --watch specfact sync intelligent my-bundle --repo . --code-to-spec auto --spec-to-code llm-prompt --tests specmatic """ - from rich.console import Console from specfact_cli.utils.structure import SpecFactStructure - console = Console() + console = get_configured_console() # Use active plan as default if bundle not provided if bundle is None: diff --git a/src/specfact_cli/runtime.py b/src/specfact_cli/runtime.py index f3153d3..1ac0c75 100644 --- a/src/specfact_cli/runtime.py +++ b/src/specfact_cli/runtime.py @@ -7,18 +7,32 @@ from __future__ import annotations +import os import sys +from enum import Enum from beartype import beartype +from icontract import ensure +from rich.console import Console from specfact_cli.modes import OperationalMode from specfact_cli.utils.structured_io import StructuredFormat +from specfact_cli.utils.terminal import detect_terminal_capabilities, get_console_config + + +class TerminalMode(str, Enum): + """Terminal output modes for Rich Console and Progress.""" + + GRAPHICAL = "graphical" # Full Rich features (colors, animations) + BASIC = "basic" # Plain text, no animations + MINIMAL = "minimal" # Minimal output (test mode, CI/CD) _operational_mode: OperationalMode = OperationalMode.CICD _input_format: StructuredFormat = StructuredFormat.YAML _output_format: StructuredFormat = StructuredFormat.YAML _non_interactive_override: bool | None = None +_console_cache: dict[TerminalMode, Console] = {} @beartype @@ -93,3 +107,55 @@ def is_non_interactive() -> bool: def is_interactive() -> bool: """Inverse helper for readability.""" return not is_non_interactive() + + +@beartype +@ensure(lambda result: isinstance(result, TerminalMode), "Must return TerminalMode") +def get_terminal_mode() -> TerminalMode: + """ + Get terminal mode based on detected capabilities and operational mode. + + Terminal modes: + - GRAPHICAL: Full Rich features (colors, animations) - interactive TTY + - BASIC: Plain text, no animations - non-interactive or CI/CD + - MINIMAL: Minimal output - test mode + + Returns: + TerminalMode enum value + """ + caps = detect_terminal_capabilities() + + # Test mode always returns MINIMAL + if os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None: + return TerminalMode.MINIMAL + + # CI/CD or non-interactive returns BASIC + if caps.is_ci or not caps.is_interactive: + return TerminalMode.BASIC + + # Interactive TTY with animations returns GRAPHICAL + if caps.supports_animations and caps.is_interactive: + return TerminalMode.GRAPHICAL + + # Fallback to BASIC + return TerminalMode.BASIC + + +@beartype +@ensure(lambda result: isinstance(result, Console), "Must return Console") +def get_configured_console() -> Console: + """ + Get or create configured Console instance based on terminal capabilities. + + Caches Console instance per terminal mode to avoid repeated detection. + + Returns: + Configured Rich Console instance + """ + mode = get_terminal_mode() + + if mode not in _console_cache: + config = get_console_config() + _console_cache[mode] = Console(**config) + + return _console_cache[mode] diff --git a/src/specfact_cli/sync/bridge_sync.py b/src/specfact_cli/sync/bridge_sync.py index 5a67bfa..b524da8 100644 --- a/src/specfact_cli/sync/bridge_sync.py +++ b/src/specfact_cli/sync/bridge_sync.py @@ -18,17 +18,18 @@ from beartype import beartype from icontract import ensure, require -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.progress import Progress from rich.table import Table from specfact_cli.adapters.registry import AdapterRegistry from specfact_cli.models.bridge import BridgeConfig +from specfact_cli.runtime import get_configured_console from specfact_cli.sync.bridge_probe import BridgeProbe from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle +from specfact_cli.utils.terminal import get_progress_config -console = Console() +console = get_configured_console() @dataclass @@ -338,11 +339,11 @@ def generate_alignment_report(self, bundle_name: str, output_file: Path | None = console.print(f"[bold red]✗[/bold red] Project bundle not found: {bundle_dir}") return + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=console, + **progress_kwargs, ) as progress: task = progress.add_task("Generating alignment report...", total=None) diff --git a/src/specfact_cli/utils/progress.py b/src/specfact_cli/utils/progress.py index c6f2a55..604fe9e 100644 --- a/src/specfact_cli/utils/progress.py +++ b/src/specfact_cli/utils/progress.py @@ -15,15 +15,12 @@ from typing import Any from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.progress import Progress from specfact_cli.models.project import ProjectBundle from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle -console = Console() - - def _is_test_mode() -> bool: """Check if running in test mode.""" return os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None @@ -89,12 +86,16 @@ def load_bundle_with_progress( Args: bundle_dir: Path to bundle directory validate_hashes: Whether to validate file checksums - console_instance: Optional Console instance (defaults to module console) + console_instance: Optional Console instance (defaults to configured console) Returns: Loaded ProjectBundle instance """ - display_console = console_instance or console + # Lazy import to avoid circular dependency + from specfact_cli.runtime import get_configured_console + from specfact_cli.utils.terminal import get_progress_config + + display_console = console_instance if console_instance is not None else get_configured_console() start_time = time() # Try to use Progress display, but fall back to direct load if it fails @@ -103,11 +104,11 @@ def load_bundle_with_progress( if use_progress: try: + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=display_console, + **progress_kwargs, ) as progress: task = progress.add_task("Loading project bundle...", total=None) @@ -149,9 +150,13 @@ def save_bundle_with_progress( bundle: ProjectBundle instance to save bundle_dir: Path to bundle directory atomic: Whether to use atomic writes - console_instance: Optional Console instance (defaults to module console) + console_instance: Optional Console instance (defaults to configured console) """ - display_console = console_instance or console + # Lazy import to avoid circular dependency + from specfact_cli.runtime import get_configured_console + from specfact_cli.utils.terminal import get_progress_config + + display_console = console_instance if console_instance is not None else get_configured_console() start_time = time() # Try to use Progress display, but fall back to direct save if it fails @@ -160,11 +165,11 @@ def save_bundle_with_progress( if use_progress: try: + progress_columns, progress_kwargs = get_progress_config() with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), + *progress_columns, console=display_console, + **progress_kwargs, ) as progress: task = progress.add_task("Saving project bundle...", total=None) diff --git a/src/specfact_cli/utils/terminal.py b/src/specfact_cli/utils/terminal.py new file mode 100644 index 0000000..36ef897 --- /dev/null +++ b/src/specfact_cli/utils/terminal.py @@ -0,0 +1,182 @@ +""" +Terminal capability detection and configuration for Rich Console and Progress. + +This module provides utilities to detect terminal capabilities (colors, animations, +interactive features) and configure Rich Console and Progress accordingly for optimal +user experience across different terminal environments (full terminals, embedded +terminals, CI/CD pipelines). +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from typing import Any + +from beartype import beartype +from icontract import ensure, require +from rich.progress import BarColumn, SpinnerColumn, TextColumn, TimeElapsedColumn + + +@dataclass(frozen=True) +class TerminalCapabilities: + """Terminal capability information.""" + + supports_color: bool + supports_animations: bool + is_interactive: bool + is_ci: bool + + +@beartype +@ensure(lambda result: isinstance(result, TerminalCapabilities), "Must return TerminalCapabilities") +def detect_terminal_capabilities() -> TerminalCapabilities: + """ + Detect terminal capabilities from environment variables and TTY checks. + + Detects: + - Color support via NO_COLOR, FORCE_COLOR, TERM, COLORTERM env vars + - Terminal type (interactive TTY vs non-interactive) + - CI/CD environment (CI, GITHUB_ACTIONS, GITLAB_CI, etc.) + - Animation support (based on terminal type and capabilities) + - Test mode (TEST_MODE env var = minimal terminal) + + Returns: + TerminalCapabilities instance with detected capabilities + """ + # Check NO_COLOR (standard env var - if set, colors disabled) + no_color = os.environ.get("NO_COLOR") is not None + + # Check FORCE_COLOR (override - if "1", colors enabled) + force_color = os.environ.get("FORCE_COLOR") == "1" + + # Check CI/CD environment + ci_vars = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "TRAVIS", "JENKINS_URL", "BUILDKITE"] + is_ci = any(os.environ.get(var) for var in ci_vars) + + # Check test mode (test mode = minimal terminal) + is_test_mode = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None + + # Check TTY (interactive terminal) + try: + is_tty = bool(sys.stdout and sys.stdout.isatty()) + except Exception: # pragma: no cover - defensive fallback + is_tty = False + + # Determine color support + # NO_COLOR takes precedence, then FORCE_COLOR, then TTY check (but not in CI) + if no_color: + supports_color = False + elif force_color: + supports_color = True + else: + # Check TERM and COLORTERM for additional hints + term = os.environ.get("TERM", "") + colorterm = os.environ.get("COLORTERM", "") + # Support color if TTY and not CI, or if TERM/COLORTERM indicate color support + supports_color = (is_tty and not is_ci) or bool(term and "color" in term.lower()) or bool(colorterm) + + # Determine animation support + # Animations require interactive TTY and not CI/CD, and not test mode + supports_animations = is_tty and not is_ci and not is_test_mode + + # Interactive means TTY and not CI/CD + is_interactive = is_tty and not is_ci + + return TerminalCapabilities( + supports_color=supports_color, + supports_animations=supports_animations, + is_interactive=is_interactive, + is_ci=is_ci, + ) + + +@beartype +@ensure(lambda result: isinstance(result, dict), "Must return dict") +def get_console_config() -> dict[str, Any]: + """ + Get Rich Console configuration based on terminal capabilities. + + Returns: + Dictionary of Console kwargs based on detected capabilities + """ + caps = detect_terminal_capabilities() + + config: dict[str, Any] = {} + + # Set force_terminal based on interactive status + # For non-interactive terminals, don't force terminal mode + if not caps.is_interactive: + config["force_terminal"] = False + + # Set no_color based on color support + if not caps.supports_color: + config["no_color"] = True + + # Set width for non-interactive terminals (default to 80) + if not caps.is_interactive: + config["width"] = 80 + + # Legacy Windows support (check if on Windows) + if sys.platform == "win32": + config["legacy_windows"] = True + + return config + + +@beartype +@ensure( + lambda result: isinstance(result, tuple) + and len(result) == 2 + and isinstance(result[0], tuple) + and isinstance(result[1], dict), + "Must return tuple of (columns tuple, kwargs dict)", +) +def get_progress_config() -> tuple[tuple[Any, ...], dict[str, Any]]: + """ + Get Rich Progress configuration based on terminal capabilities. + + Returns: + Tuple of (columns tuple, kwargs dict) for Progress initialization + Columns are passed as positional arguments, other config as kwargs + """ + caps = detect_terminal_capabilities() + + if caps.supports_animations: + # Full Rich Progress with animations + columns = ( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeElapsedColumn(), + ) + return columns, {} + # Basic Progress with text only (no animations) + columns = (TextColumn("{task.description}"),) + return columns, {"disable": False} # Still use Progress, just without animations + + +@beartype +@require(lambda current: current >= 0, "Current must be non-negative") +@require(lambda total: total >= 0, "Total must be non-negative") +@require(lambda current, total: current <= total or total == 0, "Current must not exceed total") +@ensure(lambda result: result is None, "Function returns None") +def print_progress(description: str, current: int, total: int) -> None: + """ + Print plain text progress update for basic terminal mode. + + Emits periodic status updates visible in CI/CD logs and embedded terminals. + Format: "Description... 45% (123/273)" or "Description..." if total is 0. + + Args: + description: Progress description text + current: Current progress count + total: Total progress count (0 for indeterminate) + """ + if total > 0: + percentage = (current / total) * 100 + print(f"{description}... {percentage:.0f}% ({current}/{total})", flush=True) + else: + print(f"{description}...", flush=True) diff --git a/tests/e2e/test_terminal_output_modes.py b/tests/e2e/test_terminal_output_modes.py new file mode 100644 index 0000000..b97e3e5 --- /dev/null +++ b/tests/e2e/test_terminal_output_modes.py @@ -0,0 +1,62 @@ +""" +E2E tests for terminal output in different terminal modes. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +from specfact_cli.runtime import TerminalMode, get_configured_console, get_terminal_mode +from specfact_cli.utils.terminal import detect_terminal_capabilities + + +class TestTerminalOutputE2E: + """E2E tests for terminal output modes.""" + + def test_graphical_terminal_mode(self, tmp_path: Path) -> None: + """Test that full terminal returns GRAPHICAL mode.""" + with patch.dict(os.environ, {}, clear=True): + # Remove TEST_MODE and PYTEST_CURRENT_TEST if present + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + os.environ.pop("CI", None) + with patch("sys.stdout.isatty", return_value=True): + caps = detect_terminal_capabilities() + if caps.supports_animations and caps.is_interactive: + mode = get_terminal_mode() + assert mode == TerminalMode.GRAPHICAL + console = get_configured_console() + assert console is not None + + def test_basic_terminal_mode_ci(self, tmp_path: Path) -> None: + """Test that CI environment returns BASIC mode.""" + with patch.dict(os.environ, {"CI": "true"}, clear=True): + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + console = get_configured_console() + assert console is not None + + def test_basic_terminal_mode_no_color(self, tmp_path: Path) -> None: + """Test that NO_COLOR environment returns BASIC mode.""" + with patch.dict(os.environ, {"NO_COLOR": "1"}, clear=True): + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + + def test_minimal_terminal_mode_test(self, tmp_path: Path) -> None: + """Test that TEST_MODE returns MINIMAL mode.""" + with patch.dict(os.environ, {"TEST_MODE": "true"}, clear=True): + mode = get_terminal_mode() + assert mode == TerminalMode.MINIMAL + + def test_console_consistency(self, tmp_path: Path) -> None: + """Test that get_configured_console returns consistent instances.""" + console1 = get_configured_console() + console2 = get_configured_console() + # Should return the same instance (cached) + assert console1 is console2 diff --git a/tests/integration/commands/test_terminal_output.py b/tests/integration/commands/test_terminal_output.py new file mode 100644 index 0000000..fa324a2 --- /dev/null +++ b/tests/integration/commands/test_terminal_output.py @@ -0,0 +1,104 @@ +""" +Integration tests for terminal output in different terminal modes. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +from specfact_cli.runtime import TerminalMode, get_configured_console, get_terminal_mode +from specfact_cli.utils.terminal import get_progress_config + + +class TestTerminalModeDetection: + """Test terminal mode detection in integration scenarios.""" + + def test_terminal_mode_basic_with_no_color(self, tmp_path: Path) -> None: + """Test that NO_COLOR environment variable results in BASIC mode.""" + with patch.dict(os.environ, {"NO_COLOR": "1"}, clear=True): + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + + def test_terminal_mode_basic_with_ci(self, tmp_path: Path) -> None: + """Test that CI environment variable results in BASIC mode.""" + with patch.dict(os.environ, {"CI": "true"}, clear=True): + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + + def test_console_configuration_in_basic_mode(self, tmp_path: Path) -> None: + """Test console configuration in basic terminal mode.""" + with patch.dict(os.environ, {"CI": "true"}, clear=True): + console = get_configured_console() + assert console is not None + # In basic mode, console should be configured (verify it's a Console instance) + from rich.console import Console + + assert isinstance(console, Console) + + def test_progress_configuration_in_basic_mode(self, tmp_path: Path) -> None: + """Test progress configuration in basic terminal mode.""" + with patch.dict(os.environ, {"CI": "true"}, clear=True): + columns, _kwargs = get_progress_config() + # In basic mode, should have minimal columns (no animations) + assert len(columns) == 1 # TextColumn only + assert isinstance(columns, tuple) + + +class TestCommandOutputInBasicMode: + """Test command output in basic terminal mode.""" + + def test_import_command_output_basic_mode(self, tmp_path: Path) -> None: + """Test import command produces readable output in basic mode.""" + # Create a minimal Python file for import + python_file = tmp_path / "test_module.py" + python_file.write_text("def hello(): pass\n") + + env = os.environ.copy() + env["CI"] = "true" + env["NO_COLOR"] = "1" + + # Run import command in basic mode (--no-interactive is global flag) + result = subprocess.run( + [ + sys.executable, + "-m", + "specfact_cli.cli", + "--no-interactive", + "import", + "from-code", + "test-bundle", + "--repo", + str(tmp_path), + ], + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + timeout=60, + ) + + # Should produce output (may fail due to missing dependencies, but should show output) + assert len(result.stdout) > 0 or len(result.stderr) > 0 + + def test_sync_command_output_basic_mode(self, tmp_path: Path) -> None: + """Test sync command produces readable output in basic mode.""" + env = os.environ.copy() + env["CI"] = "true" + env["NO_COLOR"] = "1" + + # Run sync command in basic mode (will likely fail due to no bundle, but should produce readable output) + result = subprocess.run( + [sys.executable, "-m", "specfact_cli.cli", "--no-interactive", "sync", "bridge", "--repo", str(tmp_path)], + capture_output=True, + text=True, + env=env, + cwd=tmp_path, + timeout=30, + ) + + # Should produce output (even if error) + assert len(result.stdout) > 0 or len(result.stderr) > 0 diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py new file mode 100644 index 0000000..a8bb501 --- /dev/null +++ b/tests/unit/test_runtime.py @@ -0,0 +1,108 @@ +""" +Unit tests for runtime configuration helpers. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +from specfact_cli.runtime import TerminalMode, get_configured_console, get_terminal_mode +from specfact_cli.utils.terminal import TerminalCapabilities + + +class TestGetTerminalMode: + """Test terminal mode detection.""" + + def test_terminal_mode_minimal_test_mode(self) -> None: + """Test that TEST_MODE returns MINIMAL.""" + with patch.dict(os.environ, {"TEST_MODE": "true"}, clear=True): + mode = get_terminal_mode() + assert mode == TerminalMode.MINIMAL + + def test_terminal_mode_minimal_pytest(self) -> None: + """Test that PYTEST_CURRENT_TEST returns MINIMAL.""" + with patch.dict(os.environ, {"PYTEST_CURRENT_TEST": "test_something"}, clear=True): + mode = get_terminal_mode() + assert mode == TerminalMode.MINIMAL + + def test_terminal_mode_basic_ci(self) -> None: + """Test that CI environment returns BASIC.""" + with ( + patch.dict(os.environ, {}, clear=True), + patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect, + ): + # Remove TEST_MODE and PYTEST_CURRENT_TEST if present + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=True, + ) + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + + def test_terminal_mode_basic_non_interactive(self) -> None: + """Test that non-interactive terminal returns BASIC.""" + with ( + patch.dict(os.environ, {}, clear=True), + patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect, + ): + # Remove TEST_MODE and PYTEST_CURRENT_TEST if present + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=False, + ) + mode = get_terminal_mode() + assert mode == TerminalMode.BASIC + + def test_terminal_mode_graphical(self) -> None: + """Test that interactive TTY with animations returns GRAPHICAL.""" + with ( + patch.dict(os.environ, {}, clear=True), + patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect, + ): + # Remove TEST_MODE and PYTEST_CURRENT_TEST if present + os.environ.pop("TEST_MODE", None) + os.environ.pop("PYTEST_CURRENT_TEST", None) + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + mode = get_terminal_mode() + assert mode == TerminalMode.GRAPHICAL + + +class TestGetConfiguredConsole: + """Test configured console creation and caching.""" + + def test_get_configured_console_creates_console(self) -> None: + """Test that get_configured_console creates a Console instance.""" + console = get_configured_console() + assert console is not None + from rich.console import Console + + assert isinstance(console, Console) + + def test_get_configured_console_caches(self) -> None: + """Test that get_configured_console caches Console instances.""" + console1 = get_configured_console() + console2 = get_configured_console() + # Should return the same instance (cached) + assert console1 is console2 + + def test_get_configured_console_different_modes(self) -> None: + """Test that different terminal modes create different Console instances.""" + # This test verifies caching works per mode + # In practice, mode doesn't change during execution, so we test caching + console1 = get_configured_console() + console2 = get_configured_console() + assert console1 is console2 diff --git a/tests/unit/utils/test_terminal.py b/tests/unit/utils/test_terminal.py new file mode 100644 index 0000000..5925810 --- /dev/null +++ b/tests/unit/utils/test_terminal.py @@ -0,0 +1,167 @@ +""" +Unit tests for terminal capability detection and configuration. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import pytest + +from specfact_cli.utils.terminal import ( + TerminalCapabilities, + detect_terminal_capabilities, + get_console_config, + get_progress_config, + print_progress, +) + + +class TestDetectTerminalCapabilities: + """Test terminal capability detection.""" + + def test_detect_no_color_env_var(self) -> None: + """Test NO_COLOR environment variable disables colors.""" + with patch.dict(os.environ, {"NO_COLOR": "1"}): + caps = detect_terminal_capabilities() + assert caps.supports_color is False + + def test_detect_force_color_env_var(self) -> None: + """Test FORCE_COLOR environment variable enables colors.""" + with ( + patch.dict(os.environ, {"FORCE_COLOR": "1"}, clear=True), + patch("sys.stdout.isatty", return_value=False), + ): + caps = detect_terminal_capabilities() + assert caps.supports_color is True + + def test_detect_ci_environment(self) -> None: + """Test CI environment detection.""" + with patch.dict(os.environ, {"CI": "true"}, clear=True): + caps = detect_terminal_capabilities() + assert caps.is_ci is True + assert caps.supports_animations is False + + def test_detect_github_actions(self) -> None: + """Test GitHub Actions environment detection.""" + with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}, clear=True): + caps = detect_terminal_capabilities() + assert caps.is_ci is True + + def test_detect_test_mode(self) -> None: + """Test TEST_MODE environment variable.""" + with ( + patch.dict(os.environ, {"TEST_MODE": "true"}, clear=True), + patch("sys.stdout.isatty", return_value=True), + ): + caps = detect_terminal_capabilities() + assert caps.supports_animations is False + + def test_detect_interactive_tty(self) -> None: + """Test interactive TTY detection.""" + with patch.dict(os.environ, {}, clear=True), patch("sys.stdout.isatty", return_value=True): + caps = detect_terminal_capabilities() + assert caps.is_interactive is True + + def test_detect_non_interactive(self) -> None: + """Test non-interactive terminal detection.""" + with patch.dict(os.environ, {}, clear=True), patch("sys.stdout.isatty", return_value=False): + caps = detect_terminal_capabilities() + assert caps.is_interactive is False + + +class TestGetConsoleConfig: + """Test console configuration generation.""" + + def test_console_config_no_color(self) -> None: + """Test console config when colors not supported.""" + with patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=False, + supports_animations=False, + is_interactive=False, + is_ci=True, + ) + config = get_console_config() + assert config["no_color"] is True + + def test_console_config_force_terminal(self) -> None: + """Test console config for non-interactive terminals.""" + with patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=False, + ) + config = get_console_config() + assert config["force_terminal"] is False + + def test_console_config_width(self) -> None: + """Test console config width for non-interactive terminals.""" + with ( + patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect, + ): + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=False, + ) + config = get_console_config() + assert config["width"] == 80 + + +class TestGetProgressConfig: + """Test progress configuration generation.""" + + def test_progress_config_with_animations(self) -> None: + """Test progress config when animations supported.""" + with patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + columns, _kwargs = get_progress_config() + assert len(columns) == 5 # SpinnerColumn, TextColumn, BarColumn, TextColumn, TimeElapsedColumn + assert isinstance(columns, tuple) + + def test_progress_config_without_animations(self) -> None: + """Test progress config when animations not supported.""" + with patch("specfact_cli.utils.terminal.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=True, + ) + columns, kwargs = get_progress_config() + assert len(columns) == 1 # TextColumn only + assert isinstance(columns, tuple) + assert kwargs.get("disable") is False + + +class TestPrintProgress: + """Test plain text progress reporting.""" + + def test_print_progress_with_total(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test print_progress with total count.""" + print_progress("Analyzing", 45, 100) + captured = capsys.readouterr() + assert "Analyzing... 45% (45/100)" in captured.out + + def test_print_progress_indeterminate(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test print_progress without total (indeterminate).""" + print_progress("Processing", 0, 0) + captured = capsys.readouterr() + assert "Processing..." in captured.out + assert "%" not in captured.out + + def test_print_progress_zero_total(self, capsys: pytest.CaptureFixture[str]) -> None: + """Test print_progress with zero total.""" + print_progress("Loading", 5, 0) + captured = capsys.readouterr() + assert "Loading..." in captured.out