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 viaSystem.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 stringauto-generates--verbose,--port 8080,--config FILE, plus a coloured--helpoutput (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.traceSuccessfulTargetemit coloured step headers and status markers tuned for build output.Trace.useWithbrackets a block with start / end coloured logs. Capability-gated automatically (no colour under CI whenTERM=dumborNO_COLOR=1). The idiomatic choice when authoringbuild.fsxscripts 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)$red[...] syntax// 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.