Skip to main content
ansicode
F#

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

Direct printfn — \x1b, \u001b, and the \NNN DECIMAL trap
// 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
Spectre.Console — F# markup with the pipeline operator
// 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)
BlackFox.ColoredPrintf — F# templated `$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 gate + the F# Interactive (FSI) REPL caveat
// 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.

Related sequences

Other languages