Console Apps in C#: Old-School Tech Making a Modern Comeback
Console applications have been with us since the dawn of computer monitors, making them one of the oldest tech tools around. But far from fading away, they’re staging a modern revival in the development world for several reasons:
- Simplicity: No need to wrestle with finicky UI frameworks.
- Versatility: Perfect for automation in CI/CD pipelines.
- Speed: Lightning-fast command execution
Console apps are proving that the classics never truly go out of style. But why are they just now making a comeback in the developer scene? The main challenge lies in usability — these tools often require users to read extensive manuals or navigate built-in help messages to understand how to use them. While this is no hurdle for seasoned professionals, it can be a deal-breaker for the average user.
Over the past year, I’ve been developing internal tools in C# to streamline my daily tasks and boost efficiency. In this article, I’ll share the lessons I’ve learned and why console apps deserve a second look.
Technical background and terms
Terminal
A computer terminal is a device that allows users to interact with a computer system often through a text-based input and output. Early terminals were physical hardware devices that connected over a network to a computer. The most famous of these terminals was the DEC VT-100 (VT100 — Wikipedia). It supported ANSI escape codes for formatting the text output.
As the technology developed the physical terminals became obsolete, but their legacy lives on in terminal emulators. A terminal emulator is a piece of software that provides support for running Console applications by supporting ANSI escape codes. It is a de-facto requirement for a terminal emulator to provide at least VT100 compatibility. Such emulator is the Windows Terminal built into Windows 11.
Console
With the launch of Windows 95, Microsoft referred to its built-in text user interface at the API level as a ‘console,’ not a ‘terminal.’ This distinction stemmed from the need to maintain compatibility with DOS applications, which were fundamentally different from Unix terminal apps. DOS lacked support for ANSI escape codes, primarily due to the memory constraints of early IBM PCs — machines that offered a mere 640KB of usable memory for user programs, a laughably small amount by today’s standards.
While the Windows console API allowed text-based functionality, it wasn’t nearly as polished or versatile as ANSI escape codes. This quirkiness made porting many great text-based apps from Linux to Windows a daunting and often unrewarding task. As a result, Windows missed out on a wealth of powerful terminal tools that thrived in the Unix world.
This changed in 2016, with the release of Windows 10 Version 1607. Since then, Windows supports the ANSI escape codes and maintains compatibility with the old console API. In 2019 They have launched the Windows Terminal project, which is the default terminal application in Windows 11 and provides much more advanced terminal features.
Shell
A shell is a command-line interface (CLI) program that allows users to interact with an operating system by entering text commands. It acts as a command interpreter, translating user inputs into instructions the operating system can execute.
Shells enable tasks like file management, program execution, and system configuration. They support scripting, allowing users to write and execute scripts to automate repetitive tasks.
Windows offers two shells by default. The “old” cmd.exe, that maintains command compatibility with DOS and the much more modern Powershell, that was written in C# with .NET. Powerhsell comes in two flavors.
Powershell and Powershell Core. The original Powershell was designed to use .NET Framework and supports only Windows, while Powershell Core was designed to use modern .NET and it’s multi-platform. You can use it under Windows, Linux and Mac. On Linux based systems the de-facto shell is Bash (Bourne Again Shell), which is also multi-platform.
Programming
In C# interacting with the user through a CLI interface is done via the Console class. This allows basic positioning, reading and writing features. Formatting the text is achieved by writing special strings to the output that is interpreted by the terminal or the terminal emulator. These special strings are called ANSI escape sequences.
Let’s see an example. Let’s say I want to display a text in italic green stating “CLI is awesome”. I can achieve this with the following Code in .NET 9 and C# 13:
Console.WriteLine("\e[3;32mCLI is awesome\e[0m");
.NET 9 and C# 13 are important in this context because of the \e character. This character, representing the start of an escape sequence, signals that the following characters control formatting.
In older versions of .NET and C#, the \e character isn’t recognized. However, that doesn’t mean escape sequences are off the table — you just need to use the Unicode equivalent, \u001b (U+001B), to indicate the start of an escape code.
The [3;32menables italic text (3) and sets the color to green (32). After the text, the \e[0m resets the formatting to default. Without this, the terminal would display everything in green italic.
There exists a Microsoft Learn page that describes all the basic escape sequences that are supported: Console Virtual Terminal Sequences — Windows Console | Microsoft Learn. However, this list is not complete, because Windows Terminal supports much more escape codes. A Good starting point to know more about the escape sequences is the following Wikipedia page: ANSI escape code — Wikipedia
UTF support
UTF is supported on Windows as well, but for legacy reasons the C# console applications don’t start with UTF enabled, they code pages to represent text. This means that every modern C# console application should start with the following instruction:
Console.OutputEncoding = System.Text.Encoding.UTF8;
By enabling UTF output we get emoji support as well.
An interactive app or a complex command app?
When designing your app, the first thing that you need to decide is how you want your users to use your application? Do you want to provide a shell-like interactive prompt, or do you want to have a complex argument driven app like for example git? Both app designs have their benefits and downsides.
A shell-like interactive CLI is typically a command-line application that allows users to interact with the program in a conversational or interactive way, similar to the C# or Python REPL.
It offers a more flexible and user-friendly experience, as users can interact with the program in real-time and issue commands without needing to specify everything upfront. The application can maintain state between commands, which allows for context-sensitive assistance or responses. On the other hand, implementing an interactive shell-like interface can be complex, as you need to manage input parsing, user session states, and possible interruptions. It may not be suitable for automated tasks or scripting, as users will need to be present to provide input during the interaction.
An argument-driven CLI app expects users to provide command-line arguments and flags that dictate the program’s behavior, like git when committing a changeset:
git commit -m "message"
Argument-driven apps are ideal for automated processes, as they allow full control over the application through scripts and batch processes, and users can specify exactly what they want in a single command. On the other hand, these tools have a steep learning curve. For beginners, it may be more difficult to understand the required syntax, especially if the app has many commands or options and there’s no ability to ask users for input in the middle of a process; everything must be specified upfront.
A minimalistic Shell like application can be implemented similarly:
using System.Text;
namespace SimpleShell;
//A basic interface that the shell commands must implement.
internal interface ICommand
{
string CommandName { get; }
void Execute(IReadOnlyList<string> args);
}
//A simple command that exits the shell.
internal class ExitCommand : ICommand
{
public string CommandName { get; } = "exit";
public void Execute(IReadOnlyList<string> args)
{
Environment.Exit(0);
}
}
//A simple command that prints "Hello, World!" and the arguments.
internal class HelloCommand : ICommand
{
public string CommandName { get; } = "hello";
public void Execute(IReadOnlyList<string> args)
{
Console.WriteLine("Hello, World!");
Console.WriteLine("Args: " + string.Join(", ", args));
}
}
internal static class Program
{
private static void Main(string[] args)
{
const string prompt = "> ";
Dictionary<string, ICommand> commands = LoadCommandsWithReflection();
while (true)
{
Console.Write(prompt);
string input = Console.ReadLine() ?? string.Empty;
(string commandName, IReadOnlyList<string> commandArgs) = Parse(input);
if (commands.TryGetValue(commandName, out ICommand? command))
{
command.Execute(commandArgs);
}
else
{
Console.WriteLine($"Command not found: {commandName}");
}
}
}
//gets the command name and arguments from the input string.
//arguments are separated by spaces. Those that are in quotes are treated as a single argument.
private static (string commandName, IReadOnlyList<string> commandArgs) Parse(string input)
{
var command = string.Empty;
var argumentList = new List<string>();
bool inQuotes = false;
StringBuilder currentArg = new(input.Length);
static void Store(ref string command, List<string> argumentList, StringBuilder currentArg)
{
if (currentArg.Length > 0)
{
if (string.IsNullOrEmpty(command))
command = currentArg.ToString();
else
argumentList.Add(currentArg.ToString());
currentArg.Clear();
}
}
foreach (char currentChar in input)
{
if (currentChar == ' ' && !inQuotes)
Store(ref command, argumentList, currentArg);
else if (currentChar == '\"')
inQuotes = !inQuotes;
else
currentArg.Append(currentChar);
}
if (currentArg.Length > 0)
Store(ref command, argumentList, currentArg);
return (command, argumentList);
}
//loads commands with reflection. This is not production grade
//since it lacks any safety checks and error handling.
private static Dictionary<string, ICommand> LoadCommandsWithReflection()
{
return typeof(ICommand).Assembly
.GetTypes()
.Where(t => !t.IsAbstract && !t.IsInterface)
.Where(t => t.IsAssignableTo(typeof(ICommand)))
.Select(t => (ICommand)Activator.CreateInstance(t)!)
.ToDictionary(c => c.CommandName, c => c);
}
}
The above code is proof of concept quality, just demonstrates ideas and shouldn’t be used as is without proper exception handling.
Argument handling is essentially the art of taming a state machine, and you have options: build your own parser from scratch or leverage a library designed for the job. Why reinvent the wheel when libraries bring extra perks like type conversion and adherence to established standards?
Speaking of standards, let’s break down a typical shell input:
program --verbose true -n d:\something.mp3
Here’s the anatomy of this input:
- Command: The first part,
program, is the executable being invoked. - Options:
— verboseis a long option, paired with the valuetrue. Long options are readable and self-descriptive. - Flags:
-nis a short flag. - Argument:
d:\something.mp3is a positional argument, an additional data passed to the command.
Both options and flags can be given in long or short form. What differentiates them is whether they have a value or not. Options precise a value, while flags are just present or not. For usability it’s recommended to have both a long and a short version for every option and flag. For example, to toggle logging verbosity you should treat the –verbose and the -v flag as the same.
A good parser must gracefully handle these and avoids relying on argument positions. Meaning that the following inputs should be treated as the same:
foo --bar c:\
foo c:\ --bar
foo -b c:\
foo c:\ -b
Implementing a simple parser can be done like this:
public class ArgumentParser
{
public List<string> Arguments = new List<string>();
public HashSet<string> Switches = new HashSet<string>();
public Dictionary<string, string> Options = new Dictionary<string, string>();
public void Parse(string[] args)
{
for (int i = 0; i < args.Length; i++)
{
string arg = args[i];
if (arg.StartsWith("--"))
{
string option = arg[2..];
if (i + 1 < args.Length && !args[i + 1].StartsWith('-'))
{
Options[option] = args[i + 1];
i++;
}
else
{
Options[option] = string.Empty;
}
}
else if (arg.StartsWith('-'))
{
foreach (char c in arg[1..])
{
Switches.Add(c.ToString());
}
}
else
{
Arguments.Add(arg);
}
}
}
}
This class separates the input into three categories: Options, Arguments and Switches. For arguments it allows duplicated inputs, but for options and flags it doesn’t. By separating the input into these categories it’s then a “simple” task to check for existence of a given argument, option and value.
We don’t have to deal with the command name and handling of the quoted inputs, since these are already done for us. The ags array of our main method doesn’t contain the program name and those values that were given in quotes are single values in the array.
To avoid the hassle, I would recommend using a library, namely Spectre.Console.Cli. In the libraries section you can find an example for it.
Designing for interoperability
Interoperability is a key challenge in modern app development, especially when your app needs to run seamlessly across Windows, Linux, and macOS. .NET supports all these platforms out of the box, but there’s a twist: not every API is compatible with every platform. This means certain API calls could throw a PlatformNotSupportedException. Thankfully, .NET’s development tools include analyzers that alert you when you try to call such unsupported APIs, making life easier.
Even better, .NET lets you version your APIs using the SupportedOSPlatform attribute, so you can specify which platforms a method or property supports. Plus, .NET provides APIs that let you check the current operating system and platform, making it simple to tailor your app’s behavior, especially when interacting with native libraries.
But wait — interoperability doesn’t stop there! There’s another sneaky trap you might overlook: file paths. Windows uses backslashes (‘\’) while Unix-based systems use forward slashes (‘/’) as path separators. Hardcoding paths like “c:\users\file.txt” will cause your app to fail on other platforms. The solution? Use the Path.Combine method, which handles these differences for you.
Now, what about configuration storage? The AppContext.BaseDirectory property points to the directory where your app runs, but it might be read-only. You can’t always count on write permissions there. The safest bet is to store configuration files in the user’s home directory. For example, to store a file called config.txt in the user’s home folder, you can use:
string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configFile = Path.Combine(userHome, "file.txt");
Finally, let’s talk about filesystems. They can behave very differently, and this matters when writing to disk, especially during unexpected events like power outages. To prevent data loss, don’t overwrite files directly. Instead, create a new file, write to it, and then rename it to replace the old one. This way, if there’s a power cut during the write process, the original file remains intact. Sure, you might lose the new file’s content, but it’s a safer approach than risking complete data loss. While it’s true that power can cut during the rename operation, the chances are low since renaming is a much faster operation than writing to disk.
string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string configFile = Path.Combine(userHome, "file.txt");
var newFile = Path.Combine(userHome, "newFile.txt.new");
//write your file here
//finally when it's done:
File.Move(configFile, newFile, overwrite: true);
Configurations and settings
The topic of configuration is not strictly related to Console applications; however, it might be important for your application to provide user controllable settings. As usual we have plenty of options to choose from when it comes to the format.
First and foremost, we must decide how we want our users to interact with the application configuration. Do we want to implement sub commands for modifying settings or do we want to allow them to edit these settings via a text editor?
If the preferred route is a text editor, then we can choose a text based, serializable format, like XML, JSON or YAML. XML and JSON have built in support in the .NET ecosystem, but when it comes to configurations, we must consider the following scenarios: The user might want to disable a setting via commenting it out and It might be a good idea to explain the setting and the possible values in a comment for the user.
The JSON standard officially doesn’t support comments. The System.Text.Json Serializer can be configured to ignore comments when reading a file, but it won’t preserve the comments, when we write the object out as a JSON.
XML supports comments, but the Built in XML serializer has an API that is 20+ years old and it has the same issue: When writing out the XML the comments are not preserved.
To solve these issues, I recommend using YAML as a configuration format. It’s a human readable and it was designed for configuration files in mind. In the .NET we don’t have built-in support for it, but the YamlDotNet library provides excellent support for reading and writing these files.
Whatever format you choose, it doesn’t save you from migration and upgrading your configurations. Upgrading is the process, where you add a new setting to your configuration and if it doesn’t exist in the old configuration, then you must create the setting with a default value. Migration is the process, when you renamed or removed a setting, and you must do something with the old configuration file to be compatible with the new version. This process can be challenging, so I recommend taking extra time when you design your configuration files.
Text based configurations might not be the best every time. Let’s suppose that your app complexity is comparable to git. In this case sooner or later you will have to store a state, configuration and other stuff somewhere on the file system. In this case using an SQLite Database managed with Entity Framework might be a good solution for your needs.
It supports Migrations and upgrades, indexes and everything that you expect from a database in a single SQLite database file. It’s multi-platform and you don’t have to worry about performance as well. The only downside is that it’s a binary format, so will have to implement commands for modifying settings in your application.
Libraries
Personally, I don’t like reinventing the wheel, so most of the time I use libraries to interact with the Terminal and make my apps. One that I use most often is called Spectre.Console, which is a .NET library that makes it easier to create beautiful console applications.
Spectre.Console
It offers formatting with an easy-to-read markup language and lots of interactive widgets (tables, trees, etc.…) and prompts (text input, single item select, and multiple items select). The best part of it is built with unit testing in mind. It has interface abstractions and a separate Testing package, that allows proper unit testing.
It also enables the creation of complex command line applications like git, gh, or dotnet with it’s Spectre.Console.Cli extesnsion package. This library is well documented on its official website: https://spectreconsole.net/

Let’s see a simple example, that demonstrates argument parsing with this library. The following program implements a simple greeter console application, that has an option for specifying the times to greet a person and a mandatory argument, that specifies who to greet:
using Spectre.Console;
using Spectre.Console.Cli;
using System.ComponentModel;
namespace SpectreCliDemo;
internal static class Program
{
private static int Main(string[] args)
{
// We create a command app with only one command
var app = new CommandApp<HelloCommand>();
// We run it
return app.Run(args);
}
}
internal sealed class HelloCommand : Command<HelloCommand.Settings>
{
// Command settings
public sealed class Settings : CommandSettings
{
// Descriptions are used for help message generation
[Description("The name to greet.")]
// <Name> indicates that it's mandatory. If it would have been optional
// [Name] would have been the syntax
[CommandArgument(0, "<NAME>")]
public string Name { get; set; } = "";
[Description("The number of times to greet the name.")]
[CommandOption("-t|--times")]
[DefaultValue(1)]
public int Times { get; set; }
// Extra, custom validation logic, if needed
public override ValidationResult Validate()
{
if (string.IsNullOrWhiteSpace(Name))
return ValidationResult.Error("Name must be given");
if (Times < 1 || Times > 100)
return ValidationResult.Error("Times must be between 1 and 100");
return ValidationResult.Success();
}
}
// Main entrypoint of the command
public override int Execute(CommandContext context, Settings settings)
{
for (int i=0; i<settings.Times; i++)
{
// The EscapeMarkup() extension encodes the [] symbols in the text
AnsiConsole.MarkupLine($"[green italic]Hello, {settings.Name.EscapeMarkup()}[/]");
}
return 0;
}
}
If I run the app without any arguments A help message is automatically generated from the attribute annotations that I added to the options class:
USAGE:
Example.dll <NAME> [OPTIONS]
ARGUMENTS:
<NAME> The name to greet
OPTIONS:
DEFAULT
-h, --help Prints help information
-t, --times 1 The number of times to greet the name
System.CommandLine
The System.CommandLine package was created specifically for the .NET Cli tool. It offers similar functionality to the Spectre.Console.Cli package, but this package is in a beta state yet. It only focuses on argument parsing as the name suggests. The previous Spectre.Console.Cli example reimplemented using this parser looks like this:
using System.CommandLine;
namespace CommandLineDemo;
internal static class Program
{
private static int Main(string[] args)
{
//define arguments and options
var timesOption = new Option<int>("-t", "The number of times to greet the name.");
var nameArgument = new Argument<string>("<NAME>", "The name to greet.");
//Add validators
nameArgument.AddValidator(nameArgument =>
{
if (string.IsNullOrWhiteSpace(nameArgument.GetValueOrDefault<string>()))
{
nameArgument.ErrorMessage = "Name cannot be empty.";
}
});
timesOption.AddValidator(timesOption =>
{
if (timesOption.GetValueOrDefault<int>() < 1)
{
timesOption.ErrorMessage = "Times must be greater than 0.";
}
});
//define the command
var helloCommand = new RootCommand("Says hello multiple times");
helloCommand.AddOption(timesOption);
helloCommand.Add(nameArgument);
helloCommand.SetHandler<string, int>(OnHello, nameArgument, timesOption);
//execute the command
return helloCommand.Invoke(args);
}
//Command logic
private static void OnHello(string name, int times)
{
for (int i=0; i < times; i++)
{
Console.WriteLine($"Hello, {name}");
}
}
}
As you can see it offers similar functionality, with a different syntax. It also generates a help message if I run the program without any arguments:
Required argument missing for command: 'Example'.
Description:
Says hello multiple times
Usage:
Example <<NAME>> [options]
Arguments:
<<NAME>> The name to greet.
Options:
-t <t> The number of times to greet the name.
--version Show version information
-?, -h, --help Show help and usage information
The library documentation can be found on Microsoft.Learn: https://learn.microsoft.com/en-us/dotnet/standard/commandline/
Prettyprompt
Prettyprompt is a useful library for shell like applications. It provides input syntax highlighting, autocomplete and many more. It can be configured and the behavior of it can be easily overridden.
It doesn’t have an extensive documentation, but to be honest it doesn’t need one. It has a very well written FruitPrompt example, that allows you to figure out how to integrate it into your project. It has some bugs, but nothing serious. You can find the library on github: https://github.com/waf/PrettyPrompt

Terminal.Gui
So far, I have been talking about the basics, but what if you wanted to build a GUI or to be more Precise a TUI application? A TUI is a Text User Interface, that relies on text-based interactions, structured layouts and keyboard navigation.
There are a few C# libraries for this task, but the most polished one is the Terminal.Gui. It is cross platform, and has an extensive documentation and template tooling and offers a ton of widgets to create stunning UIs for your terminal app.

Displaying images
The common misconception is that terminals can only be used to display text only, but that’s not the case. In the 80s the people working at DEC figured out that it would be cool, if terminals could display images besides text. For this they invented an image encoding called Sixel, which is short for “six pixels”. It encodes images as sequences of ASCII characters that can be rendered by compatible terminals.
Technically the pixels are grouped into vertical slices of 6 pixels, then each slice is encoded into an ASCII character making the data compact and readable in text streams.
Unfortunately, the drawback of this format is that not every terminal emulator supports it. That’s why the Are We Sixel Yet? website was created. It summarizes what terminal emulators support it. The stable release of Windows terminal currently does not support it yet, but the 1.22 preview release has support for it.
Without sixel support you “can” display images in a low resolution only, if your terminal supports 24-bit colors and UTF has block drawing characters. With this two you can represent pixels at an ultra-low resolution.
For sixel encoding the de-facto standard is libsixel, which can be downloaded from the following url: https://github.com/saitoha/libsixel . It is written in C and doesn’t have direct bindings to C#, but with Platform invoke it can be used. However there is an alternative.
The https://github.com/trackd/Sixel project offers a Powershell module to display Sixel images and the core part of it is written in C#, which we can reuse. The following piece of code was extracted from the above project:
The code uses the SixLabors.ImageSharp do decode and resample images.
Resampling is required because the sixel format supports a maximum of 256 colors.
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using System.Text;
namespace Sixel;
//Based on: https://github.com/trackd/Sixel
public static class SixelEncoder
{
private const char SIXELEMPTY = '?';
private const char SIXELCOLORSTART = '#';
private const char SIXELREPEAT = '!';
private const char SIXELDECGCR = '$';
private const char SIXELDECGNL = '-';
private const string SIXELSTART = $"\eP0;1q";
private const string SIXELEND = $"\e\\";
private const string SIXELTRANSPARENTCOLOR = "#0;2;0;0;0";
private const string SIXELRASTERATTRIBUTES = "\"1;1;";
private static (int width, int height) _cellSize = GetCellSize();
private static string GetControlSequenceResponse(string controlSequence)
{
char? c;
var response = string.Empty;
Console.Write($"\e{controlSequence}");
do
{
c = Console.ReadKey(true).KeyChar;
response += c;
} while (c != 'c' && Console.KeyAvailable);
return response;
}
private static (int width, int hegiht) GetCellSize()
{
var response = GetControlSequenceResponse("[16t");
try
{
var parts = response.Split(';', 't');
return (width: int.Parse(parts[2]), hegiht: int.Parse(parts[1]));
}
catch
{
// Return the default Windows Terminal size
// if we can't get the size from the terminal.
return (width: 10, hegiht: 20);
}
}
public static string ImageToSixel(Image<Rgba32> image)
{
int cellWidth = Console.WindowWidth;
image.Mutate(ctx =>
{
if (cellWidth > 0)
{
// Some math to get the target size in pixels and
// reverse it to cell height that it will consume.
var pixelWidth = cellWidth * _cellSize.width;
var pixelHeight = (int)Math.Round((double)image.Height / image.Width * pixelWidth);
// Resize the image to the target size
ctx.Resize(new ResizeOptions()
{
Sampler = KnownResamplers.Bicubic,
Size = new(pixelWidth, pixelHeight),
PremultiplyAlpha = false,
});
}
// Sixel supports 256 colors max
ctx.Quantize(new OctreeQuantizer(new()
{
MaxColors = 256,
}));
});
var targetFrame = image.Frames[0];
return FrameToSixelString(targetFrame);
}
private static string FrameToSixelString(ImageFrame<Rgba32> frame)
{
var sixelBuilder = new StringBuilder();
var palette = new Dictionary<Rgba32, int>();
var colorCounter = 1;
sixelBuilder.StartSixel(frame.Width, frame.Height);
frame.ProcessPixelRows(accessor =>
{
for (var y = 0; y < accessor.Height; y++)
{
var pixelRow = accessor.GetRowSpan(y);
// The way sixel works, this bitshift starting from the SIXELEMPTY constant
// will give us the correct character to use for the current row.
// Every six rows we swap back to the "empty character + 1" after adding a newline
// character to the string.
var c = (char)(SIXELEMPTY + (1 << (y % 6)));
var lastColor = -1;
var repeatCounter = 0;
foreach (ref var pixel in pixelRow)
{
if (!palette.TryGetValue(pixel, out var colorIndex))
{
colorIndex = colorCounter++;
palette[pixel] = colorIndex;
sixelBuilder.AddColorToPalette(pixel, colorIndex);
}
var colorId = pixel.A == 0 ? 0 : colorIndex;
if (colorId == lastColor || repeatCounter == 0)
{
lastColor = colorId;
repeatCounter++;
continue;
}
if (repeatCounter > 1)
{
sixelBuilder.AppendRepeatEntry(lastColor, repeatCounter, c);
}
else
{
sixelBuilder.AppendSixelEntry(lastColor, c);
}
lastColor = colorId;
repeatCounter = 1;
}
if (repeatCounter > 1)
{
sixelBuilder.AppendRepeatEntry(lastColor, repeatCounter, c);
}
else
{
sixelBuilder.AppendSixelEntry(lastColor, c);
}
sixelBuilder.Append(SIXELDECGCR);
if (y % 6 == 5)
{
sixelBuilder.Append(SIXELDECGNL);
}
}
});
sixelBuilder.Append(SIXELEND);
return sixelBuilder.ToString();
}
private static void AddColorToPalette(this StringBuilder sixelBuilder,
Rgba32 pixel,
int colorIndex)
{
var r = (int)Math.Round(pixel.R / 255.0 * 100);
var g = (int)Math.Round(pixel.G / 255.0 * 100);
var b = (int)Math.Round(pixel.B / 255.0 * 100);
sixelBuilder.Append(SIXELCOLORSTART)
.Append(colorIndex)
.Append(";2;")
.Append(r)
.Append(';')
.Append(g)
.Append(';')
.Append(b);
}
private static void AppendRepeatEntry(this StringBuilder sixelBuilder,
int color,
int repeatCounter,
char e)
{
sixelBuilder.Append(SIXELCOLORSTART)
.Append(color)
.Append(SIXELREPEAT)
.Append(repeatCounter)
.Append(color != 0 ? e : SIXELEMPTY);
}
private static void AppendSixelEntry(this StringBuilder sixelBuilder, int color, char e)
{
sixelBuilder.Append(SIXELCOLORSTART)
.Append(color)
.Append(color != 0 ? e : SIXELEMPTY);
}
private static void StartSixel(this StringBuilder sixelBuilder, int width, int height)
{
sixelBuilder.Append(SIXELSTART)
.Append(SIXELRASTERATTRIBUTES)
.Append(width)
.Append(';')
.Append(height)
.Append(SIXELTRANSPARENTCOLOR);
}
}
internal static class Program
{
private static void Main(string[] args)
{
var imagePath = Path.Combine(AppContext.BaseDirectory, "test.png");
var img = Image.Load<Rgba32>(imagePath);
Console.Write(SixelEncoder.ImageToSixel(img));
}
}
Running this code in Windows Terminal 1.22 or older will output the following image:

Distributing your tool
You’ve built your app — now comes the real challenge: getting it into the hands of users. On Windows, the options are endless: you can create an installer, package it for winget or Chocolatey, publish it on the Windows Store, or simply offer it as a Zip archive.
On Linux, the possibilities are just as abundant: distribute it as a tarball with an installation script, package it for your distro’s package registry, or share the source code with installation instructions.
When it comes to Mac, you can go for a Zip file, publish it on Homebrew, or create a polished DMG package.
Each platform offers its own set of benefits and challenges, and the right choice depends on your app’s needs. There are probably some options I’ve missed that could be the perfect fit for your project! My advice? Take the time to explore your options. While it might seem like a daunting task given the sheer number of choices, it’s worth the effort — especially when you factor in the added complexity of automatic updates.
.NET provides a powerful solution for CLI tools: you can package your application as a tool that’s easily installable using the dotnet tool command. When you publish your app this way, it essentially becomes a NuGet package that you upload to the NuGet registry. Once that’s done, anyone with .NET installed can simply run dotnet tool install yourappname to get it.
What’s even better is that .NET allows you to create this NuGet package automatically during the build process. All you need to do is configure a few settings in your project file. Plus, the .NET tool infrastructure supports seamless updates, so users can always stay up to date with the latest version of your app. To distribute your C# app as a .NET tool, just add these key settings to your project file:
<PropertyGroup>
<PackAsTool>True</PackAsTool>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Title>Package title</Title>
<Version>1.0.1</Version>
<Company>Program author name</Company>
<Description>
A short description of the
package that will be displayed in the NuGet registry
</Description>
<Copyright>Coppyright information</Copyright>
<PackageProjectUrl>http://your.project.website</PackageProjectUrl>
<PackageIcon>Package-icon-128x128.png</PackageIcon>
<PackageReadmeFile>readme.md.path</PackageReadmeFile>
<RepositoryUrl>http://source.repository.url</RepositoryUrl>
<RepositoryType>repo type. Eg. Git</RepositoryType>
<PackageTags>semicolon;delimited;list;of;tags;for;search</PackageTags>
<PackageReleaseNotes>Release notes text</PackageReleaseNotes>
<PackageLicenseFile>license.file</PackageLicenseFile>
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
</PropertyGroup>
Semantic Versioning is a versioning system designed to convey meaning about the changes in a software release. It uses a three-part version number: MAJOR.MINOR.PATCH.
The major version is incremented when you make incompatible API changes or break backward compatibility. E.g: If you change the way a function works or remove a feature, the major version should increase the version from 1.0.0 to 2.0.0.
The minor version is incremented when you add new functionality in a backward-compatible manner. This means that new features are introduced, but existing functionality is not broken. E.g: Adding new optional features without breaking old code should increase the version from 1.1.0 to 1.2.0.
The patch version is incremented when you make backward-compatible bug fixes or minor improvements that do not introduce new features or break anything. E.g: Fixing a bug or patching a security issue without changing any functionality should increase the version from 1.0.1 to 1.0.2.
The package icon is optional, but when you decide to use it, it must be a 128×128 pixel icon in PNG or JPEG format.
The readme.md file should provide a description of your tool in markdown format. Its content will be displayed on the NuGet package registry page for your package. For tools, it’s helpful to include several usage examples to help users quickly understand how to use it.
Selecting the right license can be a daunting task and could easily warrant its own article. It’s recommended to choose an OSI-approved open-source license. Each one comes with its own set of advantages and drawbacks. The best choice depends on your application and your specific needs. Personally, I tend to go with the MIT license because of its flexibility. You can compare different licenses on the OSI website: https://opensource.org/licenses
This article was originally published at: medium.com on the 31st of December, 2024. Original url: https://medium.com/@ruzsinszki.gabor/console-apps-in-c-old-school-tech-making-a-modern-comeback-ca71164a1db6