diff --git a/.claude/rules/terminal-ui.md b/.claude/rules/terminal-ui.md index c2770f6e..7617542b 100644 --- a/.claude/rules/terminal-ui.md +++ b/.claude/rules/terminal-ui.md @@ -5,7 +5,9 @@ paths: # Terminal UI Patterns -This rule covers TerminalUI usage, warnings system, and errors system. +This rule covers TerminalUI usage, Noora design system, warnings, and errors. + +**Design system:** Use [Noora](https://github.com/tuist/Noora) (tuist/Noora) for semantic terminal text formatting. ## TerminalUI Usage @@ -42,8 +44,9 @@ try await ui.withProgress("Downloading", total: files.count) { progress in | `BatchProgressCallback` | `@Sendable (Int, Int) -> Void` for batch progress | | `Lock` | Thread-safe state wrapper (NSLock-based, Sendable) | | `ExFigWarning` | Enum of all warning types for consistent messaging | -| `ExFigWarningFormatter` | Formats warnings as compact or multiline TOON strings | +| `ExFigWarningFormatter` | Formats warnings as compact or multiline strings | | `ExFigErrorFormatter` | Formats errors with recovery suggestions | +| `NooraUI` | Adapter for Noora design system (semantic text) | | `ConflictFormatter` | Formats batch output path conflicts for display | **TerminalOutputManager API:** @@ -93,6 +96,119 @@ await BatchProgressViewStorage.$progressView.withValue(progressView) { - Spinners and progress bars are automatically suppressed in batch mode - Critical logs (errors/warnings) coordinate with progress display via `clearForLog()` -> print -> `render()` +## Noora Design System + +Use `NooraUI` adapter for semantic terminal text formatting with ANSI colors. + +**Convenience methods** (preferred for common patterns): + +```swift +// Status messages with icons +NooraUI.formatSuccess("Build completed", useColors: true) // ✓ Build completed +NooraUI.formatError("Build failed", useColors: true) // ✗ Build failed +NooraUI.formatWarning("Deprecated API", useColors: true) // ⚠ Deprecated API +NooraUI.formatInfo("Loading config", useColors: true) // Loading config (primary) +NooraUI.formatDebug("Cache hit", useColors: true) // [DEBUG] Cache hit + +// Multi-line messages with proper indentation +NooraUI.formatMultilineError("Line 1\nLine 2", useColors: true) +// ✗ Line 1 +// Line 2 +``` + +**Low-level TerminalText API** (for custom formatting): + +```swift +import Noora + +// Format semantic text +let text: TerminalText = "Status: \(.success("OK")) for \(.primary("MyProject"))" +print(NooraUI.format(text)) + +// Available components: +// .raw(String) - No formatting +// .command(String) - System commands (highlighted) +// .primary(String) - Theme primary color +// .secondary(String) - Theme secondary color +// .muted(String) - Dimmed text +// .accent(String) - Accent color +// .danger(String) - Error/danger color +// .success(String) - Success color +``` + +**NooraUI adapter** (`Sources/ExFig/TerminalUI/NooraUI.swift`): + +| Method | Purpose | +| ------------------------------------------- | ------------------------------------- | +| `format(_ text: TerminalText)` | Convert TerminalText to ANSI str | +| `formatSuccess(_ msg, useColors:)` | Success with ✓ icon | +| `formatError(_ msg, useColors:)` | Error with ✗ icon | +| `formatWarning(_ msg, useColors:)` | Warning with ⚠ icon | +| `formatInfo(_ msg, useColors:)` | Info with primary color | +| `formatDebug(_ msg, useColors:)` | Debug with [DEBUG] prefix | +| `formatMultilineError(_ msg, useColors:)` | Multi-line error with indentation | +| `formatMultilineWarning(_ msg, useColors:)` | Multi-line warning with indent | +| `progressBarStep(message:...)` | Standalone progress bar (0-100%) | +| `progressStep(message:...)` | Standalone spinner with msg updates | + +**When to use Noora vs custom components:** + +| Use Case | Approach | Reason | +| ----------------------------- | ----------------------------------------------------- | ----------------------------------------- | +| Status messages | `NooraUI.formatSuccess/Error/etc.` | Semantic formatting with theme | +| Commands in output | `.command("exfig colors")` | Consistent command highlighting | +| Custom formatted text | `NooraUI.format(terminalText)` | Low-level semantic composition | +| Spinner/Progress in commands | Custom `ui.withSpinner()`/`ui.withProgress()` | Batch mode suppression, output coord | +| Batch multi-line progress | Custom `BatchProgressView` | Complex multi-config progress display | +| Warnings/errors via UI | `ui.warning()`/`ui.error()` | Uses Noora internally, batch-aware | +| Standalone progress bar (0-1) | `NooraUI.progressBarStep()` (new) | Known completion %, no batch mode needed | +| Standalone spinner + updates | `NooraUI.progressStep()` (new) | Dynamic message updates, no batch mode | + +**Decision matrix for progress indicators:** + +``` +Need batch mode suppression? +├── YES → Use ui.withSpinner() or ui.withProgress() +└── NO → Need percentage progress (0-100%)? + ├── YES → Use NooraUI.progressBarStep() + └── NO → Need dynamic message updates? + ├── YES → Use NooraUI.progressStep() + └── NO → Use ui.withSpinner() (default) +``` + +**Noora progress wrappers** (standalone only, bypass `TerminalOutputManager`): + +```swift +// Progress bar with percentage (0.0 to 1.0) +let result = try await NooraUI.progressBarStep( + message: "Processing...", + successMessage: "Done", + errorMessage: "Failed" +) { updateProgress in + for (i, item) in items.enumerated() { + try await process(item) + updateProgress(Double(i + 1) / Double(items.count)) + } + return items +} + +// Spinner with dynamic message updates +let result = try await NooraUI.progressStep( + message: "Loading...", + successMessage: "Loaded", + errorMessage: "Failed" +) { updateMessage in + updateMessage("Loading step 1...") + try await step1() + updateMessage("Loading step 2...") + try await step2() + return data +} +``` + +> **Warning:** Noora progress wrappers render directly to stdout, bypassing `TerminalOutputManager`. +> Do NOT use during batch mode or when other animations are active. + ## Warnings System Use `ExFigWarning` enum for all CLI warnings to ensure consistent formatting: @@ -166,3 +282,59 @@ ui.error(someError) // Auto-formats LocalizedError or falls back to localizedDe 2. Implement `errorDescription` with compact TOON format (`key=value`) 3. Implement `recoverySuggestion` with actionable fix (or `nil` for simple errors) 4. Call via `ui.error(yourError)` - formatter handles display + +## Migration Guide: Rainbow to Noora + +When creating new formatters or migrating existing ones from Rainbow to Noora: + +**1. Replace Rainbow color calls with semantic TerminalText:** + +```swift +// Before (Rainbow) +"Error: ".red + message +"✓ ".green + "Success" +"[DEBUG] ".lightBlack + message + +// After (Noora) +let text: TerminalText = "\(.danger("Error:")) \(message)" +let text: TerminalText = "\(.success("✓")) Success" +let text: TerminalText = "\(.muted("[DEBUG]")) \(message)" +``` + +**2. Use NooraUI convenience methods for common patterns:** + +```swift +// Before +let output = useColors ? "✓ \(message)".green : "✓ \(message)" + +// After +let output = NooraUI.formatSuccess(message, useColors: useColors) +``` + +**3. Semantic component mapping:** + +| Rainbow | TerminalText Component | Use Case | +| ----------------- | ---------------------- | ------------------- | +| `.red` | `.danger()` | Errors, failures | +| `.green` | `.success()` | Success messages | +| `.yellow` | `.accent()` | Warnings, highlights| +| `.cyan` | `.primary()` | Info, main content | +| `.lightBlack` | `.muted()` | Debug, secondary | +| `.bold` | `.command()` | Commands, emphasis | + +**4. Multi-line message pattern:** + +```swift +// Use formatMultilineError/Warning for proper indentation +let output = NooraUI.formatMultilineError( + "First line\nSecond line", + useColors: useColors +) +// Output: +// ✗ First line +// Second line +``` + +**5. Keep `useColors` parameter:** + +Always preserve `useColors: Bool` parameter to support `--no-color` flag and non-TTY environments. diff --git a/CLAUDE.md b/CLAUDE.md index 05d787fb..d0dce305 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,7 +203,7 @@ Templates are in `Sources/*/Resources/`. Use Stencil syntax. Update tests after | libwebp | 1.4.1+ | WebP encoding | | libpng | 1.6.45+ | PNG decoding | | swift-custom-dump | 1.3.0+ | Test assertions | -| toon-swift | 0.3.0+ | TOON format encoding | +| Noora | 0.54.0+ | Terminal UI design system | | swift-resvg | 0.45.1 | SVG parsing/rendering | | swift-docc-plugin | 1.4.5+ | DocC documentation | diff --git a/Package.resolved b/Package.resolved index 1913fce4..94f2be9a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fb10c7bf81c50182d18e97335bdb502d7129a61fa75d09cbb4ea6ea4da643734", + "originHash" : "85102de43be5b178e488500263b5b2ec0ed4a9f6f8d0978d22181a9b119f37a7", "pins" : [ { "identity" : "aexml", @@ -37,6 +37,15 @@ "version" : "1.4.1" } }, + { + "identity" : "noora", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/Noora", + "state" : { + "revision" : "bf2222995703f2a2500a2bbb8153fdb5d3a851ed", + "version" : "0.54.0" + } + }, { "identity" : "packageconfig", "kind" : "remoteSourceControl", @@ -46,6 +55,15 @@ "version" : "1.1.3" } }, + { + "identity" : "path", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tuist/path", + "state" : { + "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", + "version" : "0.3.8" + } + }, { "identity" : "pathkit", "kind" : "remoteSourceControl", @@ -150,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" } }, { @@ -163,15 +181,6 @@ "version" : "0.45.1-swift.3" } }, - { - "identity" : "toon-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/toon-format/toon-swift", - "state" : { - "revision" : "2561ea3a337c2fa42c307c0c44fdbad358c7c49c", - "version" : "0.3.0" - } - }, { "identity" : "xcodeproj", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index bed6f03b..2d2ad030 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( .package(url: "https://github.com/onevcat/Rainbow", from: "4.2.0"), .package(url: "https://github.com/the-swift-collective/libwebp.git", from: "1.4.1"), .package(url: "https://github.com/the-swift-collective/libpng.git", from: "1.6.45"), - .package(url: "https://github.com/toon-format/toon-swift", from: "0.3.0"), + .package(url: "https://github.com/tuist/Noora", from: "0.54.0"), .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"), .package(url: "https://github.com/alexey1312/swift-resvg.git", exact: "0.45.1-swift.3"), ], @@ -47,7 +47,7 @@ let package = Package( .product(name: "Rainbow", package: "Rainbow"), .product(name: "WebP", package: "libwebp"), .product(name: "LibPNG", package: "libpng"), - .product(name: "ToonFormat", package: "toon-swift"), + .product(name: "Noora", package: "Noora"), ] ), diff --git a/Sources/ExFig/TerminalUI/ExFigErrorFormatter.swift b/Sources/ExFig/TerminalUI/ExFigErrorFormatter.swift index e082a5b0..9b0f28eb 100644 --- a/Sources/ExFig/TerminalUI/ExFigErrorFormatter.swift +++ b/Sources/ExFig/TerminalUI/ExFigErrorFormatter.swift @@ -1,4 +1,5 @@ import Foundation +import Noora /// Formats LocalizedError for terminal display using TOON format. /// @@ -30,4 +31,31 @@ struct ExFigErrorFormatter { } return error.localizedDescription } + + // MARK: - TerminalText API + + /// Format a LocalizedError as semantic TerminalText. + /// - Parameter error: The error to format. + /// - Returns: A TerminalText suitable for NooraUI.format(). + func formatAsTerminalText(_ error: any LocalizedError) -> TerminalText { + let description = error.errorDescription ?? error.localizedDescription + + if let recovery = error.recoverySuggestion { + // Error message with recovery suggestion + return "\(.danger("✗")) \(.danger(description))\n → \(.muted(recovery))" + } + + return "\(.danger("✗")) \(.danger(description))" + } + + /// Format an Error as semantic TerminalText. + /// - Parameter error: The error to format. + /// - Returns: A TerminalText suitable for NooraUI.format(). + func formatAsTerminalText(_ error: any Error) -> TerminalText { + if let localizedError = error as? any LocalizedError { + return formatAsTerminalText(localizedError) + } + let description = error.localizedDescription + return "\(.danger("✗")) \(.danger(description))" + } } diff --git a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift index 4a592a9d..c234a36e 100644 --- a/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift +++ b/Sources/ExFig/TerminalUI/ExFigWarningFormatter.swift @@ -1,3 +1,5 @@ +import Noora + /// Formats ExFigWarning for terminal display using TOON format. /// /// Uses two styles: @@ -147,4 +149,30 @@ struct ExFigWarningFormatter { let suffix = count > 3 ? ", +\(count - 3) more" : "" return "Theme attributes collision: \(count) skipped, \(preview)\(suffix)" } + + // MARK: - TerminalText API + + /// Format an ExFigWarning as semantic TerminalText. + /// - Parameter warning: The warning to format. + /// - Returns: A TerminalText suitable for NooraUI.format(). + func formatAsTerminalText(_ warning: ExFigWarning) -> TerminalText { + let message = format(warning) + let lines = message.split(separator: "\n", omittingEmptySubsequences: false) + + if lines.count == 1 { + // Single line: icon + accent text + return "\(.accent("⚠")) \(.accent(message))" + } + + // Multi-line: build string with indentation, then wrap in single accent + let formattedLines = lines.enumerated().map { index, line in + if index == 0 { + "⚠ \(line)" + } else { + " \(line)" + } + }.joined(separator: "\n") + + return "\(.accent(formattedLines))" + } } diff --git a/Sources/ExFig/TerminalUI/NooraUI.swift b/Sources/ExFig/TerminalUI/NooraUI.swift new file mode 100644 index 00000000..245d400f --- /dev/null +++ b/Sources/ExFig/TerminalUI/NooraUI.swift @@ -0,0 +1,180 @@ +import Noora + +/// Adapter for Noora design system. +/// +/// Provides a shared Noora instance with default theme for consistent +/// terminal text formatting across the CLI. +/// +/// ## Usage +/// ```swift +/// let text: TerminalText = "Status: \(.success("OK"))" +/// print(NooraUI.format(text)) +/// ``` +enum NooraUI { + /// Shared Noora instance with default theme. + static let shared = Noora() + + /// Format TerminalText to a String with ANSI colors. + /// - Parameter text: Semantic terminal text to format + /// - Returns: String with ANSI escape codes for terminal display + static func format(_ text: TerminalText) -> String { + shared.format(text) + } + + // MARK: - Convenience Methods + + /// Format a success message with checkmark icon. + /// - Parameters: + /// - message: The message to display + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with success icon + static func formatSuccess(_ message: String, useColors: Bool) -> String { + guard useColors else { return "✓ \(message)" } + let text: TerminalText = "\(.success("✓")) \(message)" + return format(text) + } + + /// Format an error message with cross icon. + /// - Parameters: + /// - message: The message to display + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with error icon + static func formatError(_ message: String, useColors: Bool) -> String { + guard useColors else { return "✗ \(message)" } + let text: TerminalText = "\(.danger("✗")) \(.danger(message))" + return format(text) + } + + /// Format a warning message with warning icon. + /// - Parameters: + /// - message: The message to display + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with warning icon + static func formatWarning(_ message: String, useColors: Bool) -> String { + guard useColors else { return "⚠ \(message)" } + let text: TerminalText = "\(.accent("⚠")) \(.accent(message))" + return format(text) + } + + /// Format an info message with primary color. + /// - Parameters: + /// - message: The message to display + /// - useColors: Whether to apply colors + /// - Returns: Formatted string + static func formatInfo(_ message: String, useColors: Bool) -> String { + guard useColors else { return message } + let text: TerminalText = "\(.primary(message))" + return format(text) + } + + /// Format a debug message with muted prefix. + /// - Parameters: + /// - message: The message to display + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with debug prefix + static func formatDebug(_ message: String, useColors: Bool) -> String { + guard useColors else { return "[DEBUG] \(message)" } + let text: TerminalText = "\(.muted("[DEBUG]")) \(message)" + return format(text) + } + + /// Format multi-line error message with proper indentation. + /// - Parameters: + /// - message: The message (may contain newlines) + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with error icon and indented lines + static func formatMultilineError(_ message: String, useColors: Bool) -> String { + let lines = message.split(separator: "\n", omittingEmptySubsequences: false) + return lines.enumerated().map { index, line in + let lineStr = String(line) + if index == 0 { + return formatError(lineStr, useColors: useColors) + } else { + guard useColors else { return " \(lineStr)" } + let text: TerminalText = " \(.danger(lineStr))" + return format(text) + } + }.joined(separator: "\n") + } + + /// Format multi-line warning message with proper indentation. + /// - Parameters: + /// - message: The message (may contain newlines) + /// - useColors: Whether to apply colors + /// - Returns: Formatted string with warning icon and indented lines + static func formatMultilineWarning(_ message: String, useColors: Bool) -> String { + let lines = message.split(separator: "\n", omittingEmptySubsequences: false) + return lines.enumerated().map { index, line in + let lineStr = String(line) + if index == 0 { + return formatWarning(lineStr, useColors: useColors) + } else { + guard useColors else { return " \(lineStr)" } + let text: TerminalText = " \(.accent(lineStr))" + return format(text) + } + }.joined(separator: "\n") + } + + // MARK: - Progress Components + + /// Execute an async operation with a Noora progress bar (0-100%). + /// + /// Use this for operations with known completion percentage. + /// For indeterminate progress, use the custom `TerminalUI.withSpinner()` instead. + /// + /// - Note: This renders directly via Noora, bypassing `TerminalOutputManager`. + /// Use only for standalone operations, not during batch mode or concurrent animations. + /// + /// - Parameters: + /// - message: Initial message shown during progress + /// - successMessage: Message shown on successful completion (optional) + /// - errorMessage: Message shown on failure (optional) + /// - operation: Async closure receiving an `updateProgress(Double)` callback (0.0 to 1.0) + /// - Returns: The result of the operation + static func progressBarStep( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + operation: @escaping (@escaping (Double) -> Void) async throws -> T + ) async throws -> T { + try await shared.progressBarStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + task: operation + ) + } + + /// Execute an async operation with a Noora spinner and updateable message. + /// + /// Use this for operations where you want to update the status message dynamically. + /// For simple indeterminate progress, prefer `TerminalUI.withSpinner()` which integrates + /// with batch mode and output coordination. + /// + /// - Note: This renders directly via Noora, bypassing `TerminalOutputManager`. + /// Use only for standalone operations, not during batch mode or concurrent animations. + /// + /// - Parameters: + /// - message: Initial message shown with spinner + /// - successMessage: Message shown on successful completion (optional) + /// - errorMessage: Message shown on failure (optional) + /// - showSpinner: Whether to show animated spinner (default: true) + /// - operation: Async closure receiving an `updateMessage(String)` callback + /// - Returns: The result of the operation + static func progressStep( + message: String, + successMessage: String? = nil, + errorMessage: String? = nil, + showSpinner: Bool = true, + operation: @escaping ((String) -> Void) async throws -> T + ) async throws -> T { + try await shared.progressStep( + message: message, + successMessage: successMessage, + errorMessage: errorMessage, + showSpinner: showSpinner, + task: operation + ) + } +} diff --git a/Sources/ExFig/TerminalUI/TerminalUI.swift b/Sources/ExFig/TerminalUI/TerminalUI.swift index dc0da453..85e108de 100644 --- a/Sources/ExFig/TerminalUI/TerminalUI.swift +++ b/Sources/ExFig/TerminalUI/TerminalUI.swift @@ -31,11 +31,7 @@ final class TerminalUI: Sendable { guard outputMode != .quiet else { return } // Suppress in batch mode - progress view shows status if BatchProgressViewStorage.progressView != nil { return } - if useColors { - TerminalOutputManager.shared.print(message.cyan) - } else { - TerminalOutputManager.shared.print(message) - } + TerminalOutputManager.shared.print(NooraUI.formatInfo(message, useColors: useColors)) } /// Print a success message @@ -43,8 +39,7 @@ final class TerminalUI: Sendable { guard outputMode != .quiet else { return } // Suppress in batch mode - progress view shows status if BatchProgressViewStorage.progressView != nil { return } - let icon = useColors ? "✓".green : "✓" - TerminalOutputManager.shared.print("\(icon) \(message)") + TerminalOutputManager.shared.print(NooraUI.formatSuccess(message, useColors: useColors)) } /// Print a warning message (handles multi-line properly) @@ -63,22 +58,9 @@ final class TerminalUI: Sendable { /// Internal helper to print warning message with formatting private func printWarning(_ message: String) { - let icon = useColors ? "⚠".yellow : "⚠" - - // Split message into lines and apply formatting to each - let lines = message.split(separator: "\n", omittingEmptySubsequences: false) - - for (index, line) in lines.enumerated() { - let lineStr = String(line) - let text = useColors ? lineStr.yellow : lineStr - - if index == 0 { - // First line gets the icon - TerminalOutputManager.shared.print("\(icon) \(text)") - } else { - // Subsequent lines are indented to align with text after icon - TerminalOutputManager.shared.print(" \(text)") - } + let formatted = NooraUI.formatMultilineWarning(message, useColors: useColors) + for line in formatted.split(separator: "\n", omittingEmptySubsequences: false) { + TerminalOutputManager.shared.print(String(line)) } } @@ -115,22 +97,9 @@ final class TerminalUI: Sendable { /// Internal helper to print error message with formatting private func printError(_ message: String) { - let icon = useColors ? "✗".red : "✗" - - // Split message into lines and apply formatting to each - let lines = message.split(separator: "\n", omittingEmptySubsequences: false) - - for (index, line) in lines.enumerated() { - let lineStr = String(line) - let text = useColors ? lineStr.red : lineStr - - if index == 0 { - // First line gets the icon - TerminalOutputManager.shared.print("\(icon) \(text)") - } else { - // Subsequent lines are indented to align with text after icon - TerminalOutputManager.shared.print(" \(text)") - } + let formatted = NooraUI.formatMultilineError(message, useColors: useColors) + for line in formatted.split(separator: "\n", omittingEmptySubsequences: false) { + TerminalOutputManager.shared.print(String(line)) } } @@ -153,34 +122,19 @@ final class TerminalUI: Sendable { guard outputMode == .verbose else { return } // Suppress in batch mode - progress view shows status if BatchProgressViewStorage.progressView != nil { return } - let prefix = useColors ? "[DEBUG]".lightBlack : "[DEBUG]" - TerminalOutputManager.shared.print("\(prefix) \(message)") + TerminalOutputManager.shared.print(NooraUI.formatDebug(message, useColors: useColors)) } // MARK: - Batch Mode Log Formatting /// Format warning message for batch mode queue (includes icon and coloring) private func formatWarningForQueue(_ message: String) -> String { - let icon = useColors ? "⚠".yellow : "⚠" - let lines = message.split(separator: "\n", omittingEmptySubsequences: false) - - return lines.enumerated().map { index, line in - let lineStr = String(line) - let text = useColors ? lineStr.yellow : lineStr - return index == 0 ? "\(icon) \(text)" : " \(text)" - }.joined(separator: "\n") + NooraUI.formatMultilineWarning(message, useColors: useColors) } /// Format error message for batch mode queue (includes icon and coloring) private func formatErrorForQueue(_ message: String) -> String { - let icon = useColors ? "✗".red : "✗" - let lines = message.split(separator: "\n", omittingEmptySubsequences: false) - - return lines.enumerated().map { index, line in - let lineStr = String(line) - let text = useColors ? lineStr.red : lineStr - return index == 0 ? "\(icon) \(text)" : " \(text)" - }.joined(separator: "\n") + NooraUI.formatMultilineError(message, useColors: useColors) } // MARK: - Spinner Operations diff --git a/Sources/ExFig/TerminalUI/WarningFormatter.swift b/Sources/ExFig/TerminalUI/WarningFormatter.swift index 1eddc05a..dd0b2aa5 100644 --- a/Sources/ExFig/TerminalUI/WarningFormatter.swift +++ b/Sources/ExFig/TerminalUI/WarningFormatter.swift @@ -1,5 +1,4 @@ import ExFigCore -import ToonFormat /// Formats `AssetsValidatorWarning` for readable terminal display using TOON format struct WarningFormatter { diff --git a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift index b936c09d..f616db54 100644 --- a/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift +++ b/Tests/ExFigTests/TerminalUI/ExFigErrorFormatterTests.swift @@ -1,4 +1,5 @@ @testable import ExFig +import Noora import XCTest final class ExFigErrorFormatterTests: XCTestCase { @@ -172,3 +173,53 @@ private struct RecoverableError: LocalizedError { var errorDescription: String? { description } var recoverySuggestion: String? { recovery } } + +// MARK: - TerminalText API Tests + +extension ExFigErrorFormatterTests { + func testFormatAsTerminalTextSimpleError() { + let error = SimpleError(description: "Something went wrong") + + let text = formatter.formatAsTerminalText(error) + let formatted = NooraUI.format(text) + + // Should contain the message + XCTAssertTrue(formatted.contains("Something went wrong")) + } + + func testFormatAsTerminalTextWithRecovery() { + let error = RecoverableError( + description: "File not found", + recovery: "Check the file path exists" + ) + + let text = formatter.formatAsTerminalText(error) + let formatted = NooraUI.format(text) + + // Should contain both error and recovery + XCTAssertTrue(formatted.contains("File not found")) + XCTAssertTrue(formatted.contains("Check the file path exists")) + } + + func testFormatAsTerminalTextRecoveryOnNewLine() { + let error = RecoverableError( + description: "Error message", + recovery: "Recovery suggestion" + ) + + let text = formatter.formatAsTerminalText(error) + let formatted = NooraUI.format(text) + let lines = formatted.split(separator: "\n") + + XCTAssertEqual(lines.count, 2, "Should have error and recovery on separate lines") + } + + func testFormatAsTerminalTextProducesNonEmptyOutput() { + let error = ExFigError.accessTokenNotFound + + let text = formatter.formatAsTerminalText(error) + let formatted = NooraUI.format(text) + + XCTAssertFalse(formatted.isEmpty) + } +} diff --git a/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift b/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift index c3a60349..2253297f 100644 --- a/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift +++ b/Tests/ExFigTests/TerminalUI/ExFigWarningFormatterTests.swift @@ -1,4 +1,5 @@ @testable import ExFig +import Noora import XCTest final class ExFigWarningFormatterTests: XCTestCase { @@ -225,4 +226,37 @@ final class ExFigWarningFormatterTests: XCTestCase { XCTAssertTrue(result.contains("error=Network connection lost during request")) } + + // MARK: - TerminalText API + + func testFormatAsTerminalTextCompactWarning() { + let warning = ExFigWarning.configMissing(platform: "ios", assetType: "icons") + + let text = formatter.formatAsTerminalText(warning) + let formatted = NooraUI.format(text) + + // Should contain the message content + XCTAssertTrue(formatted.contains("Config missing")) + XCTAssertTrue(formatted.contains("platform=ios")) + } + + func testFormatAsTerminalTextMultilineWarning() { + let warning = ExFigWarning.noAssetsFound(assetType: "icons", frameName: "Icons") + + let text = formatter.formatAsTerminalText(warning) + let formatted = NooraUI.format(text) + + // Should contain multi-line content + XCTAssertTrue(formatted.contains("No assets found")) + XCTAssertTrue(formatted.contains("type: icons")) + } + + func testFormatAsTerminalTextProducesNonEmptyOutput() { + let warning = ExFigWarning.xcodeProjectUpdateFailed + + let text = formatter.formatAsTerminalText(warning) + let formatted = NooraUI.format(text) + + XCTAssertFalse(formatted.isEmpty) + } } diff --git a/Tests/ExFigTests/TerminalUI/NooraUITests.swift b/Tests/ExFigTests/TerminalUI/NooraUITests.swift new file mode 100644 index 00000000..40e9b38b --- /dev/null +++ b/Tests/ExFigTests/TerminalUI/NooraUITests.swift @@ -0,0 +1,130 @@ +@testable import ExFig +import Noora +import XCTest + +final class NooraUITests: XCTestCase { + // MARK: - Format Success + + func testFormatSuccessWithColors() { + let result = NooraUI.formatSuccess("Build completed", useColors: true) + + XCTAssertTrue(result.contains("✓")) + XCTAssertTrue(result.contains("Build completed")) + } + + func testFormatSuccessWithoutColors() { + let result = NooraUI.formatSuccess("Build completed", useColors: false) + + XCTAssertEqual(result, "✓ Build completed") + } + + // MARK: - Format Error + + func testFormatErrorWithColors() { + let result = NooraUI.formatError("Build failed", useColors: true) + + XCTAssertTrue(result.contains("✗")) + XCTAssertTrue(result.contains("Build failed")) + } + + func testFormatErrorWithoutColors() { + let result = NooraUI.formatError("Build failed", useColors: false) + + XCTAssertEqual(result, "✗ Build failed") + } + + // MARK: - Format Warning + + func testFormatWarningWithColors() { + let result = NooraUI.formatWarning("Deprecated API", useColors: true) + + XCTAssertTrue(result.contains("⚠")) + XCTAssertTrue(result.contains("Deprecated API")) + } + + func testFormatWarningWithoutColors() { + let result = NooraUI.formatWarning("Deprecated API", useColors: false) + + XCTAssertEqual(result, "⚠ Deprecated API") + } + + // MARK: - Format Info + + func testFormatInfoWithColors() { + let result = NooraUI.formatInfo("Loading config", useColors: true) + + XCTAssertTrue(result.contains("Loading config")) + } + + func testFormatInfoWithoutColors() { + let result = NooraUI.formatInfo("Loading config", useColors: false) + + XCTAssertEqual(result, "Loading config") + } + + // MARK: - Format Debug + + func testFormatDebugWithColors() { + let result = NooraUI.formatDebug("Cache hit", useColors: true) + + XCTAssertTrue(result.contains("[DEBUG]")) + XCTAssertTrue(result.contains("Cache hit")) + } + + func testFormatDebugWithoutColors() { + let result = NooraUI.formatDebug("Cache hit", useColors: false) + + XCTAssertEqual(result, "[DEBUG] Cache hit") + } + + // MARK: - Format Multiline Error + + func testFormatMultilineErrorSingleLine() { + let result = NooraUI.formatMultilineError("Single line error", useColors: false) + + XCTAssertEqual(result, "✗ Single line error") + } + + func testFormatMultilineErrorMultipleLines() { + let result = NooraUI.formatMultilineError("First line\nSecond line", useColors: false) + let lines = result.split(separator: "\n") + + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(String(lines[0]), "✗ First line") + XCTAssertEqual(String(lines[1]), " Second line") + } + + func testFormatMultilineErrorPreservesEmptyLines() { + let result = NooraUI.formatMultilineError("First\n\nThird", useColors: false) + let lines = result.split(separator: "\n", omittingEmptySubsequences: false) + + XCTAssertEqual(lines.count, 3) + } + + // MARK: - Format Multiline Warning + + func testFormatMultilineWarningSingleLine() { + let result = NooraUI.formatMultilineWarning("Single warning", useColors: false) + + XCTAssertEqual(result, "⚠ Single warning") + } + + func testFormatMultilineWarningMultipleLines() { + let result = NooraUI.formatMultilineWarning("Line 1\nLine 2", useColors: false) + let lines = result.split(separator: "\n") + + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(String(lines[0]), "⚠ Line 1") + XCTAssertEqual(String(lines[1]), " Line 2") + } + + // MARK: - Raw Format + + func testFormatTerminalText() { + let text: TerminalText = "Hello \(.success("World"))" + let result = NooraUI.format(text) + + XCTAssertTrue(result.contains("Hello")) + XCTAssertTrue(result.contains("World")) + } +} diff --git a/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/design.md b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/design.md new file mode 100644 index 00000000..62aff872 --- /dev/null +++ b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/design.md @@ -0,0 +1,203 @@ +# Design: Integrate Noora Terminal UI + +## Current Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI Commands │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TerminalUI (facade) │ +│ • info(), success(), warning(), error(), debug() │ +│ • withSpinner(), withProgress() │ +│ • createBatchProgress(), createMultiProgress() │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Spinner │ │ ProgressBar │ │BatchProgressV │ +│ (Braille) │ │ (w/ ETA) │ │ (multi) │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TerminalOutputManager (coordination) │ +│ • Prevents race conditions between animations and logs │ +│ • Manages cursor visibility, line clearing │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Rainbow (ANSI colors) │ +│ • .red, .green, .yellow, .cyan, .lightBlack │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Target Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI Commands │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TerminalUI (facade) │ +│ • info(), success(), warning(), error(), debug() │ +│ • withSpinner(), withProgress() │ +│ • createBatchProgress(), createMultiProgress() │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────┬───────────┼───────────┬───────────┐ + ▼ ▼ ▼ ▼ ▼ +┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ +│ NooraUI │ │ Spinner │ │ProgressBar│ │BatchProgr │ │ Formatters│ +│ (semantic)│ │ (Braille) │ │ (w/ ETA) │ │ (multi) │ │ (semantic)│ +└───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘ + │ │ │ │ │ + │ └─────────────┼─────────────┘ │ + │ ▼ │ + │ ┌─────────────────────────────────────┐ │ + │ │ TerminalOutputManager │ │ + │ │ (coordination layer) │ │ + │ └─────────────────────────────────────┘ │ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Noora │ +│ • TerminalText (semantic formatting) │ +│ • .success(), .danger(), .primary(), .muted() │ +│ • format() -> String with ANSI codes │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ (fallback for custom animations) +┌─────────────────────────────────────────────────────────────────┐ +│ Rainbow (legacy) │ +│ • Used by Spinner/ProgressBar for animation frames │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Component Mapping + +### Semantic Text (NooraUI) + +| ExFig Current | Noora TerminalText | +| ---------------------- | --------------------- | +| `"text".cyan` | `.primary("text")` | +| `"✓".green` | `.success("✓")` | +| `"✗".red` | `.danger("✗")` | +| `"⚠".yellow` | `.accent("⚠")` | +| `"[DEBUG]".lightBlack` | `.muted("[DEBUG]")` | +| `"command".bold` | `.command("command")` | + +### Message Formatting + +**Before (Rainbow):** + +```swift +func success(_ message: String) { + let icon = useColors ? "✓".green : "✓" + TerminalOutputManager.shared.print("\(icon) \(message)") +} +``` + +**After (Noora):** + +```swift +func success(_ message: String) { + let text: TerminalText = "\(.success("✓")) \(message)" + TerminalOutputManager.shared.print(NooraUI.format(text)) +} +``` + +### Formatter Migration + +**Before:** + +```swift +struct ExFigWarningFormatter { + func format(_ warning: ExFigWarning) -> String { + // String concatenation with manual formatting + } +} +``` + +**After:** + +```swift +struct ExFigWarningFormatter { + func format(_ warning: ExFigWarning) -> String { + NooraUI.format(formatAsTerminalText(warning)) + } + + func formatAsTerminalText(_ warning: ExFigWarning) -> TerminalText { + // Semantic TerminalText construction + } +} +``` + +## Decision Matrix: Noora vs Custom + +| Use Case | Component | Rationale | +| --------------------------------------- | ---------- | ---------------------- | +| Status messages (success/error/warning) | **Noora** | Semantic, consistent | +| Debug output | **Noora** | Simple, one-line | +| Spinner with message | **Custom** | Braille animation UX | +| Progress with ETA | **Custom** | Detailed metrics | +| Batch multi-line progress | **Custom** | Complex layout | +| Command highlighting | **Noora** | `.command()` component | + +## Trade-offs + +### Using Noora for Text Formatting + +**Pros:** + +- Semantic intent (success vs danger vs muted) +- Consistent theming across CLI +- Standard API from tuist ecosystem +- Future-proof (theme customization) + +**Cons:** + +- Additional dependency +- Slight overhead for format() call +- Learning curve for TerminalText syntax + +### Keeping Custom Animations + +**Pros:** + +- Proven UX with Braille spinner +- Precise ETA calculation +- Complex BatchProgressView layout + +**Cons:** + +- More code to maintain +- Rainbow dependency remains +- Not leveraging Noora `progressBarStep` + +## Migration Path + +### Phase 1: Non-breaking additions + +1. Add `NooraUI` adapter (done) +2. Add `formatAsTerminalText()` to formatters +3. Update TerminalUI methods to use NooraUI internally + +### Phase 2: Gradual replacement + +1. Identify simple progress use cases for `progressBarStep` +2. Migrate one command at a time +3. A/B test UX changes + +### Phase 3: Cleanup + +1. Remove unused Rainbow patterns +2. Consolidate color logic in NooraUI +3. Update documentation diff --git a/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/proposal.md b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/proposal.md new file mode 100644 index 00000000..f83d30cf --- /dev/null +++ b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/proposal.md @@ -0,0 +1,109 @@ +# Proposal: Integrate Noora Terminal UI + +**Change ID:** `integrate-noora-terminal-ui` +**Status:** Draft +**Created:** 2026-02-03 + +## Summary + +Интеграция библиотеки [Noora](https://github.com/tuist/Noora) (tuist/Noora v0.54.0) для семантического форматирования терминального вывода. Частичная замена кастомных компонентов на стандартизированные API Noora. + +## Motivation + +### Текущее состояние + +TerminalUI содержит 18 файлов с кастомными реализациями: + +| Компонент | LOC | Назначение | +| --------------------- | ---- | ---------------------------- | +| Spinner | 150 | Braille-анимация загрузки | +| ProgressBar | 250 | Прогресс с ETA | +| BatchProgressView | 400+ | Многострочный batch-прогресс | +| TerminalOutputManager | 150 | Координация вывода | +| ANSICodes | 60 | ANSI escape-коды | +| TTYDetector | 50 | Определение TTY | +| Lock | 30 | Thread-safe wrapper | +| TerminalUI | 430 | Фасад | +| Formatters (4 файла) | 300 | Форматирование сообщений | + +### Проблемы + +1. **Rainbow для цветов** — низкоуровневый, требует явных `.red`, `.green` вызовов +2. **Дублирование логики** — icon + color паттерн повторяется в info/success/warning/error +3. **Нет семантики** — цвета применяются напрямую, а не через intent (success, danger) + +### Преимущества Noora + +1. **Семантический API** — `.success("OK")`, `.danger("error")`, `.command("exfig")` +2. **Темизация** — единая цветовая схема через Noora theme +3. **Готовые компоненты** — `progressBarStep`, `yesOrNoChoicePrompt` +4. **Используется в экосистеме** — tuist, swift-index + +## Scope + +### Заменить на Noora + +| Текущее | Noora API | Приоритет | +| --------------------- | ------------------------- | --------- | +| Rainbow color calls | `TerminalText` components | P0 | +| Icon + color patterns | `.success()`, `.danger()` | P0 | +| Simple progress | `progressBarStep` | P1 | + +### Оставить кастомным + +| Компонент | Причина | +| ------------------------- | ------------------------------------------------------------ | +| **Spinner** | Braille-анимация с 12.5 FPS, уникальный UX | +| **ProgressBar** | ETA calculation, детальный счётчик (current/total) | +| **BatchProgressView** | Сложный многострочный UI с rate-limit статусом | +| **TerminalOutputManager** | Координация анимаций и логов, race condition prevention | +| **ANSICodes** | Низкоуровневые коды (cursor hide/show), не связаны с цветами | +| **Lock** | Utility, не UI | + +## Design Decision + +### Подход: Постепенная миграция + +1. **Phase 1 (P0)**: Семантическое форматирование + - Заменить Rainbow на `TerminalText` в форматтерах + - Использовать `NooraUI.format()` для вывода + - Сохранить структуру TerminalUI facade + +2. **Phase 2 (P1)**: Простой progress + - Использовать `progressBarStep` для одиночных операций без детального ETA + - Оставить кастомный ProgressBar для детального прогресса + +### Архитектурное решение + +``` +┌─────────────────────────────────────────────────────────┐ +│ TerminalUI (facade) │ +├─────────────────────────────────────────────────────────┤ +│ NooraUI.format() │ Spinner │ ProgressBar │ Batch │ +│ (semantic text) │ (custom) │ (custom) │(custom)│ +├─────────────────────────────────────────────────────────┤ +│ TerminalOutputManager │ +│ (coordination layer) │ +└─────────────────────────────────────────────────────────┘ +``` + +## Non-Goals + +- Полная замена всех компонентов на Noora +- Изменение публичного API TerminalUI +- Удаление Rainbow (останется для legacy и edge cases) + +## Risks + +| Risk | Mitigation | +| ------------------- | --------------------------------------------------- | +| Noora API изменится | Закрепить версию 0.54.0+, NooraUI адаптер изолирует | +| Производительность | Noora format() — O(1), не критично | +| Совместимость тем | Использовать default theme | + +## Success Criteria + +1. Форматтеры используют `TerminalText` для семантики +2. Вывод визуально идентичен (цвета сохранены) +3. Тесты форматтеров проходят +4. Build без warnings diff --git a/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/specs/terminal-ui/spec.md b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/specs/terminal-ui/spec.md new file mode 100644 index 00000000..da01cb45 --- /dev/null +++ b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/specs/terminal-ui/spec.md @@ -0,0 +1,94 @@ +# Terminal UI Spec Delta + +## ADDED Requirements + +### Requirement: NooraUI adapter SHALL provide semantic text formatting + +The CLI SHALL provide a `NooraUI` adapter that wraps Noora library for semantic terminal text formatting with consistent theming. + +#### Scenario: Format success message with semantic component + +**Given** a message "Operation completed" +**When** formatted using `NooraUI.format("\(.success("✓")) Operation completed")` +**Then** the output contains ANSI green color codes for the checkmark +**And** the message text is uncolored + +#### Scenario: Format error message with semantic component + +**Given** an error message "Failed to connect" +**When** formatted using `NooraUI.format("\(.danger("✗")) Failed to connect")` +**Then** the output contains ANSI red color codes for the cross icon +**And** the message text is uncolored + +#### Scenario: Format command reference in message + +**Given** a help message mentioning command "exfig colors" +**When** formatted using `NooraUI.format("Run \(.command("exfig colors")) to export")` +**Then** the command is highlighted distinctly from surrounding text + +--- + +### Requirement: TerminalUI MUST use NooraUI for message formatting + +The TerminalUI facade MUST use NooraUI internally for consistent semantic formatting of info, success, warning, error, and debug messages. + +#### Scenario: Success message uses Noora semantic formatting + +**Given** TerminalUI with colors enabled +**When** `success("Export completed")` is called +**Then** the output uses `.success()` component for the checkmark icon +**And** the message is printed via TerminalOutputManager + +#### Scenario: Warning message uses Noora semantic formatting + +**Given** TerminalUI with colors enabled +**When** `warning("Config not found")` is called +**Then** the output uses `.accent()` component for the warning icon +**And** multi-line messages are properly indented + +#### Scenario: Error message uses Noora semantic formatting + +**Given** TerminalUI with colors enabled +**When** `error("Connection failed")` is called +**Then** the output uses `.danger()` component for the error icon +**And** multi-line messages are properly indented + +--- + +### Requirement: Formatters SHALL return semantic TerminalText + +Warning and error formatters SHALL provide methods to return `TerminalText` for semantic formatting in addition to plain string output. + +#### Scenario: ExFigWarningFormatter returns TerminalText + +**Given** an `ExFigWarning.configMissing` warning +**When** `formatAsTerminalText()` is called +**Then** the result is a `TerminalText` with semantic components +**And** the text can be formatted via `NooraUI.format()` + +#### Scenario: ExFigErrorFormatter returns TerminalText + +**Given** a `LocalizedError` with recovery suggestion +**When** `formatAsTerminalText()` is called +**Then** the error description uses `.danger()` component +**And** the recovery suggestion uses `.muted()` component + +## MODIFIED Requirements + +### Requirement: Custom animations SHALL remain unchanged + +The Spinner, ProgressBar, and BatchProgressView components SHALL continue to use the existing Rainbow-based rendering for animation frames while message formatting migrates to Noora. + +#### Scenario: Spinner animation uses Rainbow for frame colors + +**Given** a Spinner with colors enabled +**When** the spinner is running +**Then** the Braille animation frames use Rainbow `.cyan` for coloring +**And** the spinner message can use Noora-formatted text + +#### Scenario: ProgressBar uses Rainbow for bar rendering + +**Given** a ProgressBar with colors enabled +**When** progress is updated +**Then** the filled bar uses Rainbow `.cyan` for coloring +**And** the empty bar uses Rainbow `.lightBlack` for coloring diff --git a/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/tasks.md b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/tasks.md new file mode 100644 index 00000000..0f2ed667 --- /dev/null +++ b/openspec/changes/archive/2026-02-03-integrate-noora-terminal-ui/tasks.md @@ -0,0 +1,68 @@ +# Tasks: Integrate Noora Terminal UI + +## Phase 1: Semantic Text Formatting (P0) + +- [x] **1.1** Extend `NooraUI` adapter with convenience methods for common patterns + - Add `formatSuccess(icon:message:)`, `formatError(icon:message:)`, etc. + - Map to `.success()`, `.danger()`, `.muted()` components + +- [x] **1.2** Migrate `TerminalUI.info()` to use `NooraUI.format()` + - Replace `message.cyan` with `TerminalText` `.primary()` component + - Preserve batch mode suppression logic + +- [x] **1.3** Migrate `TerminalUI.success()` to use `NooraUI.format()` + - Replace `"✓".green` with `.success("✓")` + - Preserve batch mode suppression logic + +- [x] **1.4** Migrate `TerminalUI.warning()` to use `NooraUI.format()` + - Replace `"⚠".yellow` with `.accent("⚠")` or custom warning component + - Preserve multi-line formatting logic + +- [x] **1.5** Migrate `TerminalUI.error()` to use `NooraUI.format()` + - Replace `"✗".red` with `.danger("✗")` + - Preserve multi-line formatting logic + +- [x] **1.6** Migrate `TerminalUI.debug()` to use `NooraUI.format()` + - Replace `"[DEBUG]".lightBlack` with `.muted("[DEBUG]")` + +- [x] **1.7** Update `ExFigWarningFormatter` to return `TerminalText` + - Convert string-based formatting to semantic components + - Add `formatAsTerminalText()` method alongside existing `format()` + +- [x] **1.8** Update `ExFigErrorFormatter` to return `TerminalText` + - Use `.danger()` for error messages + - Use `.muted()` for recovery suggestions + +- [x] **1.9** Update tests for formatters + - Verify output matches expected semantic structure + - Add tests for `NooraUI.format()` output + +## Phase 2: Progress Components (P1) + +- [x] **2.1** Add `NooraUI.progressBarStep()` wrapper + - Added `NooraUI.progressBarStep()` and `NooraUI.progressStep()` wrappers + - Note: These bypass `TerminalOutputManager`, use only for standalone operations + +- [x] **2.2** Evaluate replacing simple `withSpinner` with `progressBarStep` + - **Decision: Keep both, no migration** + - Custom `Spinner` is deeply integrated with `TerminalOutputManager` and batch mode + - 80+ call sites depend on batch mode suppression and output coordination + - Noora wrappers available for new isolated use cases only + +- [x] **2.3** Document when to use Noora vs custom components + - Updated `.claude/rules/terminal-ui.md` with decision matrix + - Added Noora progress wrappers documentation + +## Validation + +- [x] **V1** Run full test suite: `./bin/mise run test` +- [x] **V2** Manual testing: verify colors in TTY terminal +- [x] **V3** Manual testing: verify plain output in non-TTY (CI) +- [x] **V4** Build on Linux: verify no Noora-specific issues + +## Documentation + +- [x] **D1** Update CLAUDE.md dependencies table (done) +- [x] **D2** Update `.claude/rules/terminal-ui.md` with Noora patterns (done) +- [x] **D3** Add migration guide for future formatters + - Added "Migration Guide: Rainbow to Noora" section to terminal-ui.md