ANSI escape codes in F# — printfn, \x1b, \027 is DECIMAL not octal, Spectre.Console
F# string literals accept `\x1b` directly (since F# 4.5, August 2018), `\u001b` (Unicode codepoint — works in every F# version including .NET Framework F# 2.0), and `\NNN` triplets — but **F# `\NNN` is DECIMAL, not octal** (the .NET runtime quirk shared with C# / VB.NET / PowerShell, and the opposite of C / Erlang / Java where `\033` is octal 27). The portability trap: copy `"\033[31m"` from a C / Erlang / Bash snippet into F# and you get the BYTE 33 (= `!`), not ESC. Use `"\x1b"` (F# 4.5+) or `"\027"` (decimal triple, all versions) or `"\u001b"` (Unicode codepoint, all versions) — never `"\033"`. The canonical F# one-liner is `printfn "\x1b[1;31merror:\x1b[0m %s" msg` — `printf` / `printfn` is F#'s type-checked printf family (the format string is parsed at COMPILE time, unlike C# / Java where format strings are validated at runtime). `%s` interpolates a string, `%d` an int, `%A` any value via structured pretty-print. Escape bytes embedded in the format string pass through to `System.Console.Out` unchanged. The `printfn` newline is platform-aware (`\r\n` on Windows, `\n` on Unix) — use plain `printf` if you're building a single line incrementally. For anything richer than ad-hoc escapes, **`Spectre.Console`** is the de facto modern .NET console toolkit — works identically from F# (`AnsiConsole.MarkupLine "[bold red]error:[/] permission denied"`). Pair it with **`Argu`** (F#'s canonical CLI parser — auto-coloured `--help` output, type-safe argument records via discriminated unions) for full F# script ergonomics. For F#-idiomatic templated coloured output reach for **`BlackFox.ColoredPrintf`** — `cprintfn "$red[error:] %s" msg` uses `$colour[...]` template syntax that compiles to standard ANSI. For F# build scripts (FAKE) the **`Fake.Core.Trace`** module emits colourised step headers, success / failure markers, and structured logging native to the FAKE DSL. **The F# Interactive (FSI) REPL caveat** (page's primary SERP differentiator): `dotnet fsi` from a normal terminal prompt parses ANSI in `printfn` output — colours work as expected. BUT inside Visual Studio's *F# Interactive* tool window AND inside the VS Code Ionide *F# Interactive* output pane, ANSI escape bytes are rendered **literally** as `\x1b[31m...` text — those panes are text widgets that don't dispatch through a VT terminal emulator. The fix: detect via `not Console.IsOutputRedirected && not (isNull (Environment.GetEnvironmentVariable "VSAPPIDDIR"))` (Visual Studio sets `VSAPPIDDIR`; Ionide / VS Code sets `VSCODE_PID` and `TERM_PROGRAM=vscode`) to skip ANSI in IDE-hosted FSI sessions, or set `NO_COLOR=1` in the FSI launch profile. Capability gating: `System.Console.IsOutputRedirected` (true when piped / redirected — skip colour to avoid poisoning files), `System.Environment.GetEnvironmentVariable("NO_COLOR")` (universal opt-out), and `System.Console.OutputEncoding <- System.Text.Encoding.UTF8` (box-drawing + emoji safety — defaults vary by OS / culture).
Recommended libraries
- Spectre.Console
The de facto modern .NET console toolkit — works identically from F#. `AnsiConsole.MarkupLine "[bold red]error:[/] permission denied"`, tables, trees, progress, prompts, exception rendering. Auto-detects terminal capability (TrueColor / 256 / 16 / no-colour) and falls back gracefully. F# pipeline (`|>`) composes cleanly with Spectre's builder APIs.
- BlackFox.ColoredPrintf
F#-idiomatic colored printf — `cprintfn "Hello $red[%s], your build $green[succeeded] in $yellow[%d]ms" name ms`. The `$colour[...]` template syntax wraps the inner text with the matching SGR sequence + reset. Compiles to standard ANSI bytes; auto-disables when stdout is not a TTY via `System.Console.IsOutputRedirected`. Lightweight (one NuGet, ~200 LOC) — ideal when you want template-level colour expressions without Spectre's full TUI machinery.
- Argu
F#-canonical CLI parser — type-safe argument records via discriminated unions. `type Args = | Verbose | Port of int | Config of string` auto-generates `--verbose`, `--port 8080`, `--config FILE`, plus a coloured `--help` output (section headers in bold, option names in cyan, descriptions in default — capability-gated). The right call for any F# script / tool with non-trivial arguments; widely used in the F# community and FAKE / Paket / Fable themselves.
- Fake.Core.Trace
FAKE build-script trace module — `Trace.trace`, `Trace.traceImportant`, `Trace.traceError`, `Trace.traceSuccessfulTarget` emit coloured step headers and status markers tuned for build output. `Trace.useWith` brackets a block with start / end coloured logs. Capability-gated automatically (no colour under CI when `TERM=dumb` or `NO_COLOR=1`). The idiomatic choice when authoring `build.fsx` scripts under FAKE 5+.
Idiomatic patterns
// F# accepts THREE useful ESC forms in "..." literals.
// All compile to the same single byte (0x1b = 27).
//
// "\x1b" — hex (F# 4.5+ / August 2018)
// "\u001b" — Unicode codepoint (every F# version, even F# 2.0)
// "\027" — \NNN is DECIMAL in F# (.NET runtime convention,
// shared with C# / VB.NET / PowerShell)
//
// THE PORTABILITY TRAP:
// "\033" — DOES NOT WORK as ESC in F#!
// \033 in F# is decimal 33 (= byte 0x21 = "!").
// In C / Erlang / Java, \033 is octal 27 (= ESC).
// Copy-pasting from those languages silently breaks.
//
// When porting from C / Erlang / Bash, replace every "\033"
// with "\x1b" (or "\027" if you need pre-F#-4.5 compatibility).
module AnsiDemo
open System
open System.Text
[<EntryPoint>]
let main _ =
// Box-drawing + emoji safety — defaults vary by OS / culture.
Console.OutputEncoding <- Encoding.UTF8
// All three forms produce the same red "error:" output:
printfn "\x1b[1;31merror:\x1b[0m permission denied"
printfn "\u001b[1;31merror:\u001b[0m permission denied"
printfn "\027[1;31merror:\027[0m permission denied"
// Truecolor — 38;2;R;G;B
printfn "\x1b[38;2;255;128;0morange truecolor\x1b[0m"
// 256-palette — 38;5;n
printfn "\x1b[38;5;196mbright red\x1b[0m"
// F# printf is TYPE-CHECKED at compile time. The format
// string is parsed by the compiler, not at runtime:
let msg = "permission denied"
printfn "\x1b[1;31merror:\x1b[0m %s" msg
let tests, failed = 142, 3
printfn "\x1b[32mrun:\x1b[0m %d tests, \x1b[31m%d failed\x1b[0m" tests failed
// Reusable helper — pure F#:
let esc (sgr: string) (text: string) =
sprintf "\x1b[%sm%s\x1b[0m" sgr text
printfn "%s" (esc "32" "OK")
printfn "%s" (esc "1;31" "FAIL")
0// dotnet add package Spectre.Console
open Spectre.Console
// AnsiConsole.MarkupLine works identically from F#:
AnsiConsole.MarkupLine "[bold red]error:[/] permission denied"
AnsiConsole.MarkupLine "[yellow]warn:[/] deprecated flag"
AnsiConsole.MarkupLine "[green]ok:[/] 142 tests passed"
// Truecolor — RGB triplet or hex:
AnsiConsole.MarkupLine "[#ff8000]orange truecolor[/]"
AnsiConsole.MarkupLine "[rgb(255,128,0)]same, RGB form[/]"
// F# pipeline (`|>`) composes cleanly with Spectre builders:
let table =
Table()
|> fun t -> t.AddColumns("File", "Status")
|> fun t -> t.AddRow("app.log", "[green]ok[/]")
|> fun t -> t.AddRow("db.log", "[red]missing[/]")
AnsiConsole.Write table
// Spectre's status spinner — async-friendly via F# computation:
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.Start("Compiling…", fun ctx ->
System.Threading.Thread.Sleep 500
ctx.Status <- "[yellow]Type-checking…[/]"
System.Threading.Thread.Sleep 500
)
// Exception rendering — coloured stack frames out of the box:
try
raise (System.InvalidOperationException "demo")
with ex ->
AnsiConsole.WriteException(ex, ExceptionFormats.ShortenEverything)// dotnet add package BlackFox.ColoredPrintf
open BlackFox.ColoredPrintf
// $colour[...] template syntax wraps inner text with SGR + reset.
// Compiles to standard ANSI bytes; auto-disables when stdout
// is not a TTY (via System.Console.IsOutputRedirected).
cprintfn "$red[error:] permission denied"
cprintfn "$yellow[warn:] deprecated flag"
cprintfn "$green[ok:] 142 tests passed"
// Nest %s / %d / %A inside the template — type-checked at
// compile time like every F# printf:
let name, ms = "build", 1240
cprintfn "Hello $red[%s], your $green[succeeded] in $yellow[%d]ms" name ms
// Multiple colours per line — read top-to-bottom like prose:
let port = 8080
cprintfn "$cyan[[INFO]] started on port $magenta[%d]" port
let tests, failed = 142, 3
cprintfn "$green[run:] %d tests, $red[%d failed]" tests failed
// Available colours: red green yellow blue magenta cyan white
// black + their "bright" / "dark" variants
// e.g. $darkred[...] $brightcyan[...]
cprintfn "$darkred[fatal:] $brightyellow[stack overflow]"
// Plain Console.Out is the destination — pipe through a file
// and the colour template auto-elides:
// dotnet run > out.txt # plain text (no escape bytes)
// dotnet run # ANSI in terminal
// NO_COLOR=1 dotnet run # plain text (env opt-out)
()// Capability gating in F#:
// System.Console.IsOutputRedirected — true when piped
// System.Environment.GetEnvironmentVariable("NO_COLOR")
// System.Environment.GetEnvironmentVariable("FORCE_COLOR")
//
// THE FSI REPL CAVEAT (page's primary SERP differentiator):
// `dotnet fsi` from a normal terminal prompt parses ANSI in
// printfn output — colours work as expected. BUT inside
// Visual Studio's *F# Interactive* tool window AND inside
// the VS Code Ionide *F# Interactive* output pane, ANSI
// escape bytes are rendered LITERALLY as "\x1b[31m..." text
// — those panes are text widgets that don't dispatch
// through a VT terminal emulator.
//
// Detect IDE-hosted FSI sessions and skip colour:
open System
let isIdeHostedFsi () =
// Visual Studio sets VSAPPIDDIR.
// Ionide / VS Code sets VSCODE_PID + TERM_PROGRAM=vscode.
not (isNull (Environment.GetEnvironmentVariable "VSAPPIDDIR"))
|| (Environment.GetEnvironmentVariable "TERM_PROGRAM" = "vscode"
&& not (isNull (Environment.GetEnvironmentVariable "VSCODE_PID")))
let ansiCapable () =
// The Unix-standard kill switch — honour first.
if not (isNull (Environment.GetEnvironmentVariable "NO_COLOR"))
then false
// Explicit opt-in overrides redirection.
elif not (isNull (Environment.GetEnvironmentVariable "FORCE_COLOR"))
then true
// Piped / redirected output — don't poison logs.
elif Console.IsOutputRedirected then false
// IDE-hosted FSI panes render escapes literally.
elif isIdeHostedFsi () then false
else true
let style (sgr: string) (text: string) =
if ansiCapable ()
then sprintf "\x1b[%sm%s\x1b[0m" sgr text
else text
printfn "%s" (style "32" "OK")
printfn "%s" (style "1;31" "FAIL")
// FAKE build-script tracing — capability-gated automatically:
//
// #r "nuget: Fake.Core.Trace"
// open Fake.Core
//
// Trace.trace "starting build"
// Trace.traceImportant "compiling Foo.fsproj"
// Trace.traceError "test failed: BarSpec.fs:42"
// Trace.traceSuccessfulTarget "Build" :: []
// |> ignore
//
// Fake.Core.Trace honours NO_COLOR and TERM=dumb out of the
// box — no manual gating needed inside build.fsx.