PrettyConsole is a high-performance, ultra-low-latency, allocation-free extension layer over System.Console. The library uses C# extension members (extension(Console)) so every API lights up directly on System.Console once using PrettyConsole; is in scope. It is trimming/AOT ready, preserves SourceLink metadata, and keeps the familiar console experience while adding structured rendering, menus, progress bars, and advanced input helpers.
- 🚀 Zero-allocation interpolated string handler (
PrettyConsoleInterpolatedStringHandler) for inline colors and formatting - 🎨 Inline color composition with
ConsoleColortuples and helpers (DefaultForeground,DefaultBackground,Default) plusAnsiColorsutilities when you need raw ANSI sequences - 🔁 Advanced rendering primitives (
Overwrite,ClearNextLines,GoToLine,SkipLines, progress bars) that respect console pipes - 🧱 Handler-aware
WhiteSpacestruct for zero-allocation padding directly inside interpolated strings - 🧰 Rich input helpers (
TryReadLine,Confirm,RequestAnyInput) withIParsable<T>and enum support - ⚙️ Allocation-conscious span-first APIs (
ISpanFormattable,ReadOnlySpan<char>,Console.WriteWhiteSpaces/TextWriter.WriteWhiteSpaces) - ⛓ Output routing through
OutputPipe.OutandOutputPipe.Errorso piping/redirects continue to work
BenchmarkDotNet measures styled output performance for a single line write:
| Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| PrettyConsole | 55.96 ns | 90.23x faster | - | - | NA |
| SpectreConsole | 5,046.29 ns | baseline | 2.1193 | 17840 B | |
| SystemConsole | 71.64 ns | 70.44x faster | 0.0022 | 24 B | 743.333x less |
PrettyConsole is the go-to choice for ultra-low-latency, allocation-free console rendering, running 90X faster than Spectre.Console while allocating nothing and even beating the manual unrolling with the BCL.
dotnet add package PrettyConsoleStandalone samples made with .NET 10 file-based apps with preview clips are available in Examples.
using PrettyConsole; // Extension members + OutputPipe
using static System.Console; // Optional for terser call sitesThis setup lets you call Console.WriteInterpolated, Console.Overwrite, Console.TryReadLine, etc. The original System.Console APIs remain available—call System.Console.ReadKey() or System.Console.SetCursorPosition() directly whenever you need something the extensions do not provide.
PrettyConsoleInterpolatedStringHandler now buffers interpolated content in a pooled buffer before flushing to the selected pipe. Colors auto-reset at the end of each call. Console.WriteInterpolated and Console.WriteLineInterpolated return the number of visible characters written (handler-emitted escape sequences are excluded) so you can drive padding/width calculations from the same call sites.
Console.WriteInterpolated($"Hello {ConsoleColor.Green / ConsoleColor.DefaultBackground}world{ConsoleColor.Default}!");
Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.Yellow / ConsoleColor.DefaultBackground}warning{ConsoleColor.Default}: {message}");
if (!Console.TryReadLine(out int choice, $"Pick option {ConsoleColor.Cyan / ConsoleColor.DefaultBackground}1-5{ConsoleColor.Default}: ")) {
Console.WriteLineInterpolated($"{ConsoleColor.Red / ConsoleColor.DefaultBackground}Not a number.{ConsoleColor.Default}");
}
// Zero-allocation padding directly from the handler
Console.WriteInterpolated($"Header{new WhiteSpace(6)}Value");ConsoleColor.DefaultForeground, ConsoleColor.DefaultBackground, and the / operator overload make it easy to compose foreground/background tuples inline (ConsoleColor.Red / ConsoleColor.White).
When ANSI escape sequences are safe to emit (Console.IsOutputRedirected/IsErrorRedirected are both false), the Markup helper exposes ready-to-use toggles for underline, bold, italic, and strikethrough:
Console.WriteLineInterpolated($"{Markup.Bold}Build{Markup.ResetBold} {Markup.Underline}completed{Markup.ResetUnderline} in {elapsed:duration}"); // e.g. "completed in 2h 3m 17s"All fields collapse to string.Empty when markup is disabled, so the same call sites continue to work when output is redirected or the terminal ignores decorations. Use Markup.Reset if you want to reset every decoration at once.
-
TimeSpan :durationformat — the interpolated string handler understands the custom:durationspecifier. It emits integerhours/minutes/secondstokens (e.g.,5h 32m 12s,27h 12m 3s,123h 0m 0s) without allocations, and the hour component keeps growing past 24 so long-running tasks stay accurate. Minutes/seconds are not zero-padded so the output stays compact:var elapsed = stopwatch.Elapsed; Console.WriteInterpolated($"Completed in {elapsed:duration}"); // Completed in 12h 5m 33s
-
double :bytesformat — pass anydouble(cast integral sizes if needed) with the:bytesspecifier to render human-friendly binary size units. Values scale by powers of 1024 throughB,KB,MB,GB,TB,PB, and use the#,##0.##format so thousands separators and up to two decimal digits follow the current culture:var transferred = 12_884_901d; Console.WriteInterpolated($"Uploaded {transferred:bytes}"); // Uploaded 12.3 MB Console.WriteInterpolated($"Remaining {remaining,8:bytes}"); // right-aligned units stay tidy
-
Alignment — standard alignment syntax works the same way it does with regular interpolated strings, but the handler writes directly into the console buffer. This keeps columnar output zero-allocation friendly:
Console.WriteInterpolated($"|{"Label",-10}|{value,10:0.00}|");
You can combine both, e.g., $"{elapsed,8:duration}", to keep progress/status displays tidy.
-
WhiteSpacestruct for padding — passnew WhiteSpace(length)inside an interpolated string to emit that many spaces straight from the handler without allocating intermediate strings. -
Custom escape sequences — if you need your own ANSI code (extra markup/colors), keep it in an interpolated hole instead of hardcoding it into the literal so the handler can treat it like other escape spans:
var rose = "\u001b[38;5;213m"; // custom 256-color escape
Console.WriteInterpolated($"{rose}accent text{Markup.Reset}");Avoid embedding the escape directly in the literal ("\u001b[38;5;213maccent text"), which would be measured as visible width and could skew padding/alignment.
// Interpolated text
Console.WriteInterpolated($"Processed {items} items in {elapsed:duration}"); // Processed 42 items in 3h 44m 9s
Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Magenta}debug{ConsoleColor.Default}");
// Span + color overloads (no boxing)
ReadOnlySpan<char> header = "Title";
Console.Write(header, OutputPipe.Out, ConsoleColor.White, ConsoleColor.DarkBlue);
Console.NewLine(); // writes newline to the default output pipe
// ISpanFormattable (works with ref structs)
Console.Write(percentage, OutputPipe.Out, ConsoleColor.Cyan, ConsoleColor.DefaultBackground, format: "F2", formatProvider: null);Behind the scenes these overloads rent buffers from the shared ArrayPool<char> and route output to the correct pipe through ConsoleContext.GetWriter.
if (!Console.TryReadLine(out int port, $"Port ({ConsoleColor.Green}5000{ConsoleColor.Default}): ")) {
port = 5000;
}
// `TryReadLine<TEnum>` and `TryReadLine` with defaults
if (!Console.TryReadLine(out DayOfWeek day, ignoreCase: true, $"Day? ")) {
day = DayOfWeek.Monday;
}
var apiKey = Console.ReadLine($"Enter API key ({ConsoleColor.DarkGray}optional{ConsoleColor.Default}): ");All input helpers work with IParsable<T> and enums, respect the active culture, and honor OutputPipe when prompts are colored.
Console.RequestAnyInput($"Press {ConsoleColor.Yellow}any key{ConsoleColor.Default} to continue…");
if (!Console.Confirm($"Deploy to production? ({ConsoleColor.Green}y{ConsoleColor.Default}/{ConsoleColor.Red}n{ConsoleColor.Default}) ")) {
return;
}
var customTruths = new[] { "sure", "do it" };
bool overwrite = Console.Confirm(customTruths, $"Overwrite existing files? ", emptyIsTrue: false);Console.ClearNextLines(3, OutputPipe.Error);
int line = Console.GetCurrentLine();
// … draw something …
Console.GoToLine(line);
Console.SetColors(ConsoleColor.White, ConsoleColor.DarkBlue);
Console.ResetColors();
Console.SkipLines(2); // keep multi-line UIs (progress bars, dashboards) and continue writing below themConsoleContext.Out/Error expose the live writers (both are settable if you need to swap in test doubles). Use Console.WriteWhiteSpaces(int length, OutputPipe pipe) for convenient padding from call sites, or call WriteWhiteSpaces(int) on an existing writer. Console.SkipLines(n) advances the cursor without clearing so you can keep overwritten UI (progress bars, spinners, dashboards) visible after completion:
Console.WriteWhiteSpaces(8, OutputPipe.Error); // pad status blocks
ConsoleContext.Error.WriteWhiteSpaces(4); // same via writerConsole.Overwrite(() => {
Console.WriteLineInterpolated(OutputPipe.Error, $"{ConsoleColor.Cyan}Working…{ConsoleColor.Default}");
Console.WriteInterpolated(OutputPipe.Error, $"{ConsoleColor.DarkGray}Elapsed:{ConsoleColor.Default} {stopwatch.Elapsed:duration}"); // Elapsed: 0h 1m 12s
}, lines: 2);
// Prevent closure allocations with state + generic overload
Console.Overwrite((left, right), tuple => {
Console.WriteInterpolated($"{tuple.left} ←→ {tuple.right}");
}, lines: 1);
await Console.TypeWrite("Booting systems…", (ConsoleColor.Green, ConsoleColor.Black));
await Console.TypeWriteLine("Ready.", ConsoleColor.Default);Always call Console.ClearNextLines(totalLines, pipe) once after the last Overwrite to erase the region when you are done.
var choice = Console.Selection("Pick an environment:", new[] { "Dev", "QA", "Prod" });
var multi = Console.MultiSelection("Services to restart:", new[] { "API", "Worker", "Scheduler" });
var (area, action) = Console.TreeMenu("Actions", new Dictionary<string, IList<string>> {
["Users"] = new[] { "List", "Create", "Disable" },
["Jobs"] = new[] { "Queue", "Retry" }
});
Console.Table(
headers: new[] { "Name", "Status" },
columns: new[] {
new[] { "API", "Worker" },
new[] { "Running", "Stopped" }
}
);Menus validate user input (throwing ArgumentException on invalid selections) and use the padding helpers internally to keep columns aligned.
using var progress = new ProgressBar {
ProgressChar = '■',
ForegroundColor = ConsoleColor.DarkGray,
ProgressColor = ConsoleColor.Green,
};
for (int i = 0; i <= 100; i += 5) {
progress.Update(i, $"Downloading chunk {i / 5}");
await Task.Delay(50);
}
// Need separate status + bar lines? sameLine: false
progress.Update(42.5, "Syncing", sameLine: false);
// One-off render without state
ProgressBar.Render(OutputPipe.Error, 75, ConsoleColor.Magenta, '*', maxLineWidth: 32);ProgressBar.Update always re-renders (even if the percentage didn't change) so you can refresh status text. You can also set ProgressBar.MaxLineWidth on the instance to limit the rendered [=====] 42% line width before each update, mirroring the maxLineWidth option on ProgressBar.Render. The helper ProgressBar.Render keeps the cursor on the same line, which is ideal inside Console.Overwrite, and accepts an optional maxLineWidth so the entire [=====] 42% line can be constrained for left-column layouts. For dynamic headers, use the overload that accepts a PrettyConsoleInterpolatedStringHandlerFactory, mirroring the spinner pattern.
Spinner renders animated frames on the error pipe. PrettyConsoleInterpolatedStringHandlerFactory overloads take a lambda that creates a PrettyConsoleInterpolatedStringHandler via the builder for per-frame headers:
var spinner = new Spinner();
await spinner.RunAsync(workTask, (builder, out handler) =>
handler = builder.Build(OutputPipe.Error, $"Syncing {DateTime.Now:T}"));The factory runs each frame so you can inject dynamic status text without allocations while avoiding extra struct copies.
using System.Linq;
using System.Threading.Channels;
var downloads = new[] { "Video.mp4", "Archive.zip", "Assets.pak" };
var progress = new double[downloads.Length];
var updates = Channel.CreateUnbounded<(int index, double percent)>();
// Producers push progress updates
var producers = downloads
.Select((name, index) => Task.Run(async () => {
for (int p = 0; p <= 100; p += Random.Shared.Next(5, 15)) {
await updates.Writer.WriteAsync((index, p));
await Task.Delay(Random.Shared.Next(40, 120));
}
}))
.ToArray();
// Consumer renders stacked bars each time an update arrives
var consumer = Task.Run(async () => {
await foreach (var (index, percent) in updates.Reader.ReadAllAsync()) {
progress[index] = percent;
Console.Overwrite(progress, state => {
for (int i = 0; i < state.Length; i++) {
Console.WriteInterpolated(OutputPipe.Error, $"Task {i + 1} ({downloads[i]}): ");
ProgressBar.Render(OutputPipe.Error, state[i], ConsoleColor.Cyan);
}
}, lines: downloads.Length, pipe: OutputPipe.Error);
}
});
await Task.WhenAll(producers);
updates.Writer.Complete();
await consumer;
Console.ClearNextLines(downloads.Length, OutputPipe.Error); // ensure no artifacts remainEach producer reports progress over the channel, the consumer loops with ReadAllAsync, and Console.Overwrite redraws the stacked bars on every update. After the consumer completes, clear the region once to remove the progress UI.
PrettyConsole keeps the original console streams accessible (and settable for tests) via ConsoleContext:
TextWriter @out = ConsoleContext.Out;
TextWriter @err = ConsoleContext.Error;
TextReader @in = ConsoleContext.In;Use these when you need direct writer access (custom buffering, WriteWhiteSpaces, etc.) or swap in mocks for testing. In cases where you must call raw System.Console APIs (e.g., Console.ReadKey(true)), do so explicitly—PrettyConsole never hides the built-in console.
Contributions are welcome! Fork the repo, create a branch, and open a pull request. Bug reports and feature requests are tracked through GitHub issues.
For bug reports, feature requests, or sponsorship inquiries reach out at dusrdev@gmail.com.
This project is proudly made in Israel 🇮🇱 for the benefit of mankind.