Decode escape codes in the wild
Hit an escape-laden log line, a recorded TUI session, or a stray .ans file? Pipe it through one of these recipes — or drag the bookmarklet to your bookmarks bar and send any selected text straight into the on-site decoder.
Last updated
Where escape codes show up
CI logs are the usual suspect — GitHub Actions and GitLab strip SGR when rendering the web UI but archive the raw bytes, so the colours come back as soon as you download the raw log and pipe it through `less -R`. `script(1)` typescripts and asciinema casts both capture every escape verbatim (asciinema also keeps the timing for replay). Container runtimes and systemd pass the app's stdout through unchanged even though `docker logs` / `kubectl logs` / `journalctl` aren't TTYs — that's where the `^[[31m` literal noise in your terminal comes from. Log shippers each pick a side: Loki and Vector preserve SGR by default, fluentbit / fluentd typically strip via a filter plugin. Dotfiles + prompt frameworks (oh-my-zsh, starship, p10k) are the other major source — every theme is a sea of `\x1b[` sequences. Pick a recipe below that fits your situation.
Bookmarklet — send selected text to the decoder
Drag the link below to your browser's bookmarks bar. Select escape-laden text on ANY page, click the bookmarklet, and a new tab opens on ansicode.eversources.app/decode with the text pre-filled — fully tokenized and rendered.
Bookmarklet sourcejavascript:(function(){var t=window.getSelection().toString();if(!t){t=window.prompt("Paste escape-laden text:")||'';}if(t){window.open("https://ansicode.eversources.app/en/decode"+'?text='+encodeURIComponent(t),'_blank','noopener');}})();void(0);If your browser strips the link on drop, copy the source below and paste it as the URL of a new manually-created bookmark.
CLI recipes
Decoding without leaving the terminal — pick the recipe that fits your context.
# Stream a remote log through an inline tokenizer
# (replace the URL with whatever stream you have):
curl -fsSL https://example.com/build.log | python3 -c '
import sys, re
ANSI = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
data = sys.stdin.read()
i = 0
for m in ANSI.finditer(data):
sys.stdout.write(data[i:m.start()])
sys.stdout.write(f"«{m.group(0)!r}»")
i = m.end()
sys.stdout.write(data[i:])
'Pipe any command's output through a tiny inline Python that tags every escape and otherwise lets text through unchanged. Useful for CI logs, journalctl, or any stream you can't pipe into `less -R`.
# JavaScript / Node parity with the Python recipe above.
# Same regex (CSI + OSC + bare ESC), same « … » wrapping output.
# Pure Node 18+ — no npm install, no transpile.
curl -fsSL https://example.com/build.log | node -e '
const ANSI = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
process.stdin.setEncoding("utf8");
let buf = "";
process.stdin.on("data", (c) => { buf += c; });
process.stdin.on("end", () => {
process.stdout.write(buf.replace(ANSI, (m) => "«" + JSON.stringify(m) + "»"));
});
'
# For a live tail:
# tail -F app.log | node -e '...'JavaScript parity with the Python recipe — same regex, same `«…»` brace-wrapping output. Useful when your build environment ships Node but not Python (npm-only CI images, Electron projects, JS monorepo dev machines). Pure Node 18+ — no `npm install`, no transpile. For TypeScript projects `tsx -e '…'` accepts the same script body.
# Deno 2+ on TypeScript — typed, no npm install, no transpile step.
# Stdin isn't capability-gated, so the inline form runs with zero flags.
curl -fsSL https://example.com/build.log | deno eval '
const ANSI = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
const dec = new TextDecoder();
let buf = "";
for await (const chunk of Deno.stdin.readable) buf += dec.decode(chunk);
console.log(buf.replace(ANSI, (m) => "«" + JSON.stringify(m) + "»"));
'
# Saved + typed version (decode-ansi.ts) — same logic, full TypeScript:
# deno run decode-ansi.ts < build.log
const ANSI: RegExp = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
const dec = new TextDecoder();
let buf = "";
for await (const chunk of Deno.stdin.readable) buf += dec.decode(chunk);
console.log(buf.replace(ANSI, (m: string) => `«${JSON.stringify(m)}»`));
# Network-fetch variant — capability-gated to one host, no shell-pipe needed:
deno eval --allow-net=example.com '
const ANSI = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
const txt = await (await fetch("https://example.com/build.log")).text();
console.log(txt.replace(ANSI, (m) => "«" + JSON.stringify(m) + "»"));
'Same regex as the Node recipe, but expressed in idiomatic Deno: stdin is the Web-streams `Deno.stdin.readable` (`ReadableStream<Uint8Array>`), text decoded with the standard `TextDecoder`. Three flavours: (a) **inline `deno eval`** — zero install, zero flags, drop-in for a piped log stream. (b) **typed saved script** (`decode-ansi.ts`) — same logic with TypeScript annotations so `deno run` type-checks the regex + lambda for you; useful for repo dotfiles + CI scripts. (c) **network-fetch form** with explicit `--allow-net=example.com` capability gate — Deno's allow-list model means a script that only needs to fetch one host can be sandboxed to exactly that, unlike Node which has all-or-nothing network access. Pick Deno over Node when the target machine already has Deno (avoids `npm install` for a one-shot tool), when you want typed code without a `tsconfig.json` + `tsx` toolchain, or when sandbox guarantees matter (untrusted log sources, supply-chain-conscious environments).
# Rust + regex::bytes — byte-safe inline tokenizer.
# regex::bytes skips UTF-8 boundary checks (inner bytes inside CSI / OSC
# are guaranteed ASCII by spec), so it's the right choice for log streams
# whose encoding around the escapes isn't yours to control.
# Single-file form via rust-script — drop a #!-prefixed .rs with an
# inline Cargo manifest, chmod +x, treat it like a Python script.
cargo install rust-script # one-time
cat > decode-ansi.rs <<'EOF'
#!/usr/bin/env rust-script
//! ```cargo
//! [dependencies]
//! regex = "1"
//! ```
use regex::bytes::Regex;
use std::io::{self, Read, Write};
fn main() -> io::Result<()> {
let mut buf = Vec::new();
io::stdin().read_to_end(&mut buf)?;
let ansi = Regex::new(
r"\x1b(?:[@-Z\\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))"
).unwrap();
let mut last = 0;
let mut out = io::stdout().lock();
for m in ansi.find_iter(&buf) {
out.write_all(&buf[last..m.start()])?;
write!(out, "«{}»", buf[m.range()].escape_ascii())?;
last = m.end();
}
out.write_all(&buf[last..])
}
EOF
chmod +x decode-ansi.rs
curl -fsSL https://example.com/build.log | ./decode-ansi.rs
# Cargo project form — what you'd commit alongside a TUI in ratatui /
# crossterm / termion so the same regex serves byte-stream + render:
# cargo new --bin decode-ansi && cd decode-ansi
# # add `regex = "1"` under [dependencies] in Cargo.toml
# # paste fn main() above into src/main.rs
# cargo run --release --quiet < build.log
# Companion crate for TUI alignment — pair with `unicode-width` (UCS9
# port) rather than libc `wcwidth(3)`; results disagree across glibc /
# musl / macOS. See /pitfalls/wcwidth-disagreement.Rust parity with the Python / Node / Deno tokenizers — same canonical CSI / OSC / bare-ESC pattern, same `«…»` brace-wrapping output. Uses `regex::bytes::Regex` rather than the default `&str` variant because the inner bytes inside escape envelopes are guaranteed ASCII by spec; there's no UTF-8 boundary to honour, and the bytes-flavoured regex is faster on log streams whose encoding around the escapes isn't yours to control. Two delivery forms: (a) **`rust-script` single-file** — drop a `#!`-prefixed `.rs` with an inline Cargo manifest, `chmod +x`, treat it like a Python script (one-time `cargo install rust-script`). (b) **regular Cargo bin** — what you'd commit alongside a TUI in `ratatui` / `crossterm` / `termion` so the same regex serves both the byte stream and the rendering side. The `escape_ascii()` slice method (stable since 1.60) renders matched bytes as printable `\x1b[31m` form without an intermediate `String` allocation. When you also need column widths for TUI layout, pair with `unicode-width` (Rust port of UCS9) rather than libc `wcwidth(3)` — results disagree across glibc / musl / macOS, so vendoring a known Unicode revision is the safer move (cross-ref `/pitfalls/wcwidth-disagreement`).
# Go + regexp (RE2) — backtrack-free inline tokenizer.
# Go's stdlib regexp is RE2 — guaranteed linear-time on hostile input, so
# you can run it across untrusted log streams without catastrophic-regex
# risk. Same envelope as the Python / Node / Deno / Rust recipes: CSI +
# OSC + bare-ESC, each match wrapped in « … ».
# Single-file form via 'go run' — no module init, no install, no binary
# on disk. Great for one-shot pipeline use.
cat > decode-ansi.go <<'EOF'
package main
import (
"bufio"
"fmt"
"io"
"os"
"regexp"
)
var ansi = regexp.MustCompile(
"\x1b(?:[@-Z\\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))",
)
func main() {
buf, err := io.ReadAll(bufio.NewReader(os.Stdin))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out := ansi.ReplaceAllFunc(buf, func(m []byte) []byte {
return []byte(fmt.Sprintf("«%q»", m))
})
os.Stdout.Write(out)
}
EOF
curl -fsSL https://example.com/build.log | go run decode-ansi.go
# Module form — what you'd commit alongside a TUI in cobra / bubbletea /
# lipgloss / fang so the same regex serves byte-stream + render:
# mkdir decode-ansi && cd decode-ansi
# go mod init example.com/decode-ansi
# # paste the file above into main.go
# go build -o decode-ansi && ./decode-ansi < build.log
# Capability detection — pair with golang.org/x/term for isatty + colour
# gating (the same pattern the charmbracelet stack uses):
# if term.IsTerminal(int(os.Stdout.Fd())) { /* emit colour */ }
# See /use/go for fatih/color + lipgloss usage.Go's stdlib `regexp` is RE2 — guaranteed linear-time on hostile input, so the tokenizer runs safely across untrusted log streams without the catastrophic-regex risk Python's `re` can hit. Same canonical CSI / OSC / bare-ESC envelope as the Python / Node / Deno / Rust recipes, same `«…»` brace-wrapping output. Two delivery forms: (a) **single-file via `go run`** — no `go mod init`, no install, no binary on disk; the source file IS the script. Drop-in for one-shot pipelines where you don't want to manage build artefacts. (b) **module form** — `go mod init` + `go build -o decode-ansi` produces a single static binary you can commit alongside a TUI in `cobra` / `bubbletea` / `lipgloss` / `fang`, so the same regex serves both byte stream and rendering side. For capability detection (`isatty` + colour gating) pair with `golang.org/x/term`'s `term.IsTerminal(int(os.Stdout.Fd()))` — the same pattern the charmbracelet stack uses. Cross-ref `/use/go` for `fatih/color` and `lipgloss` library guidance.
# PowerShell 7+ on Windows Terminal — emit + decode ANSI natively.
# (Windows PowerShell 5.1 still ships with Windows; escape hatch below.)
# Emit colour: PS 7+ has `e as the ESC escape inside double-quoted strings:
Write-Host "`e[1;31mERROR`e[0m something broke"
# Windows PowerShell 5.1 (no `e support) — build ESC manually:
$e = [char]27
Write-Host "$e[1;31mERROR$e[0m something broke"
# Decode an escape-laden file: wrap every CSI / OSC / bare-ESC sequence
# in « … » brackets so they're visible in the host. Pure PowerShell —
# .NET regex handles \e (ESC) and \a (BEL) natively, no extra escaping.
Get-Content build.log -Raw -Encoding UTF8 |
ForEach-Object { [regex]::Replace($_,
'\e(?:[@-Z\\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\a\e]*(?:\a|\e\\))',
'«$&»') } |
Out-Host
# Force-on / force-off colour rendering (PS 7.2+ master switch):
$PSStyle.OutputRendering = 'Ansi' # always render escape codes
$PSStyle.OutputRendering = 'PlainText' # always strip escape codes
$PSStyle.OutputRendering = 'Host' # default: TTY renders, pipe strips
# Honour the cross-platform NO_COLOR convention:
if ($env:NO_COLOR) { $PSStyle.OutputRendering = 'PlainText' }PowerShell 7+ (the cross-platform `pwsh`) renders ANSI natively when its host supports virtual-terminal output — Windows Terminal, ConEmu, the VS Code integrated terminal, and the modernised Windows 11 console all qualify. Use `` `e `` as the ESC shorthand inside PS 7+ double-quoted strings; on Windows PowerShell 5.1 (still bundled with Windows) `` `e `` doesn't exist — build ESC manually with `[char]27`. The decode-file recipe wraps each escape in `«…»` using `[regex]::Replace`; .NET regex handles `\e` (ESC) and `\a` (BEL) natively, so the pattern is the bash regex with no extra escaping. `$PSStyle.OutputRendering` is the PS 7.2+ master switch — `Ansi` forces colour through any redirection, `PlainText` strips, `Host` (default) renders on TTY and strips on pipe. Honour `$env:NO_COLOR` the same way you would in bash.
# Wrap every escape sequence in « … » brackets so they are visible
# but not interpreted. Works with mawk/gawk/BSD awk:
awk '{
gsub(/\x1b(\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|P[^\x1b]*\x1b\\|[@-Z\\\\-_])/, "«&»");
print
}' build.log
# Grep variant — show only lines that contain an SGR (CSI ... m) escape:
grep -E $'\x1b\[[0-9;]*m' build.logWraps every CSI / OSC / DCS / bare-ESC sequence in `«…»` brackets so escapes become visible without losing the surrounding text — read-only, no rendering attempted. Drop-in for grepping a recorded session for, say, every `\x1b[3` (italic) byte.
# Render colour log instead of seeing literal ^[[31m noise:
less -R build.log
# Persist across sessions — paginate everything with -R:
echo 'export LESS=R' >> ~/.bashrc # or ~/.zshrc
# Note: -R (uppercase) keeps SGR/colour escapes active while preserving
# UTF-8 width math. Avoid -r (lowercase) — it renders every control
# character literally and breaks column alignment on East-Asian text.`less` strips control characters by default and so flattens colour into `^[[31m` noise. `less -R` (or `LESS=R` exported globally) keeps every CSI SGR sequence active so colourised output renders as the author intended. `-r` (lowercase) renders ALL control chars but breaks UTF-8 width math; prefer `-R` for ANSI logs.
# Container runtimes + systemd stash whatever the app wrote to stdout
# — including SGR escapes — but the default consumer (kubectl, docker,
# journalctl) is rarely a real terminal, so colour bytes pass through
# unrendered. Two recipes, depending on what you want to do with them.
# RENDER colour the way the app intended:
docker logs -f mycontainer 2>&1 | less -R
kubectl logs -f deploy/api -n prod | less -R
journalctl -u nginx --no-pager | less -R
# STRIP colour for grep / archival / log-aggregator ingestion:
docker logs mycontainer 2>&1 | ansifilter > clean.log # canonical, apt install ansifilter
docker logs mycontainer 2>&1 | sed -E 's/\x1b\[[0-9;?]*[a-zA-Z]//g' > clean.log
# 'ansifilter' covers CSI + OSC + DCS comprehensively.
# The 'sed' fallback covers CSI only — fine for ~95% of real app output
# (everything SGR / DEC mode) but leaves OSC 8 hyperlinks and DCS Sixel
# data in place. Pick 'sed' when you can't add a package; 'ansifilter'
# when correctness matters.Container runtimes and systemd both stash whatever the app wrote to stdout — including SGR escapes — but the default consumer (kubectl / docker / journalctl) is rarely a real terminal, so colour bytes pass through unrendered. Pipe through `less -R` to see colour as the app intended; or strip with `ansifilter` / `sed` for grep, archival, or ingestion into a log aggregator that doesn't accept control characters. `ansifilter` covers CSI + OSC + DCS comprehensively; the `sed` fallback covers CSI only — fine for ~95% of real app output but leaves OSC 8 hyperlinks and DCS Sixel data in place.