Skip to content
Fran Gonzalez
← Back to blog
·Clanker·4 min read

Tty-aware shell wrappers for OSC 8 hyperlinks in eza, ripgrep, and fd

Shell functions that add OSC 8 hyperlink flags to eza, ripgrep, and fd only when stdout is a terminal, so clickable paths never leak into pipes or files.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

I wrap eza, ripgrep, and fd in shell functions that emit OSC 8 hyperlinks only when their output goes to a terminal, so clickable paths never leak into pipes or files.

The Problem

OSC 8 hyperlinks turn each path into a clickable link (cmd-click in kitty opens the file). The mechanism is raw escape bytes wrapped around the visible text:

\e]8;;file:///abs/path\e\\filename\e]8;;\e\\

A static alias like alias rg='rg --hyperlink-format=default' injects those bytes unconditionally. That breaks everything downstream: rg foo | wc -l counts the escape bytes, rg foo > matches.txt writes garbage, and any tool parsing the output sees the escape sequence instead of a path.

What I want is hyperlinks when a human reads a terminal, and plain text everywhere else.

What Changed

tty-aware shell-function wrappers in ~/.commonrc, each guarded by _have so they are only defined where the tool exists:

# ~/.commonrc
# _have is defined once near the top of the file
_have() { type "$1" &>/dev/null; }

# ------------------------- conditional tools ------------------------
# Terminal hyperlinks (OSC 8) for kitty plain-click on paths
if _have eza; then
  unalias ls 2>/dev/null
  ls() {
    if [[ -t 1 ]]; then command eza --hyperlink=auto "$@"
    else command eza "$@"; fi
  }
  alias lt='eza --tree --level=2 --long --icons --git --hyperlink=auto'
fi

if _have rg; then
  rg() {
    if [[ -t 1 ]]; then command rg --hyperlink-format=default "$@"
    else command rg "$@"; fi
  }
fi

if _have fd; then
  fd() {
    if [[ -t 1 ]]; then command fd --hyperlink=auto "$@"
    else command fd "$@"; fi
  }
fi

Why each piece is the way it is:

  • [[ -t 1 ]] is true only when file descriptor 1 (stdout) is a real terminal. ls > out.txt, rg foo | wc -l, and fd . | xargs rm all take the else branch and get clean output.
  • command eza calls the real binary instead of the function. Without it, ls() would call itself forever.
  • A function, not an alias. Aliases are static text substitution; they cannot make a runtime decision based on whether stdout is a tty, so the branch needs a function. For eza I also unalias ls first, because a plain ls alias is defined earlier in the same config and would otherwise shadow the function.
  • The _have guard means one .commonrc ports across machines that may lack eza, rg, or fd without printing errors.

The flag differs per tool: eza and fd take --hyperlink=auto, ripgrep takes --hyperlink-format=default. eza and fd’s flags are clap optional-value options, so the value must be attached with =. A bare --hyperlink swallows the next argument. I wrote about that failure mode separately.

Results

Direct invocation produces clickable paths; piping or redirecting the same command produces clean output. The difference is visible with cat -v. Unwrapped eza piped into a file dumps the raw OSC 8 sequence:

$ command eza --hyperlink=always src/content/blog | head -1 | cat -v
^[]8;;file:///Users/fran/.../a-cognito-...-powersync.md^[\a-cognito-...-powersync.md^[]8;;^[\

The wrapped ls, piped through the same chain, is clean:

$ ls src/content/blog | head -1 | cat -v
a-cognito-token-pair-contract-api-gateway-authorizer-spring-hono-and-powersync.md

And rg, piped into anything, behaves like the bare binary:

$ rg -n "draft: true" src/content/blog/eza-hyperlink-eats-the-next-positional-argument.md | head -1
5:draft: true

Run interactively (stdout is a tty), the same commands emit OSC 8 links that kitty cmd-click opens.

There is one more line that hands the hyperlink job to kitty’s own ripgrep kitten:

_have kitten && hg() { kitten hyperlinked_grep "$@"; }

What I’d Do Differently

The three wrappers look repetitive, and I considered generating them from a table of {command, flag} pairs. The per-tool flag names differ (--hyperlink vs --hyperlink-format), and the eza wrapper needs the unalias ls step, so a generator would hide more than it saves. Three readable functions win.

References

This post was written with AI assistance.