Empower your graphical terminal with a smart "ls" wrapper

Hello all !

Apologies in advance for the sounds-like-boasting tone, but I would like to share with you what started like a weekend inspiration that actually spanned a bit longer than I thought it would, but which produced a result I really could not live without now on any Unicode-capable graphical terminal. Quite simply, with it I almost rarely ever use my desktop's file manager any longer now 😅

Here's what. With a little bit of shellcode and the help of awk, it made "ls" show directory listings like this:

1788615617810.png

It doesn't pollute scripts: when the standard output is not a Unicode-capable terminal (e.g. a pipe, or a CI/CD build script), the original 'ls' is used so the behaviour is unchanged. This display is carefully designed to prettify the ls output only when you, the interactive user, are typing the command in an interactive terminal.

I find the extra information given by icons invaluably helpful. I immediately see 📁 folders, 🚀 executables, 🔗 symlinks, 🪈 pipes and ☢️ setuid programs. These are the most common but it can also display 📠 character devices, 💽 block devices, UNIX domain 🔌 sockets, 📄 regular files, union mount 🚫 whiteouts each under their own, distinct icon (I see where you're looking at: indeed, at some point, I thought about assigning a different icon according to the file type e.g. a picture icon for image files, another one for videos, etc. but while experimenting around I found that idea a bit excessive, confusing and eventually less useful than the purely mode-based icons for file management, which is what 'ls' is merely about, so I chose to limit it to that).

All of the 'ls' options are supported, quite simply in the sense that if a 'ls' option is entered that the code doesn't know how to handle, it will fallback on the default 'ls', thus feature parity is guaranteed. 'ls -x' (cross-ordering), 'ls -R' (recursive), 'ls -n', 'ls -i', ls on multiple target folders, are usable with this pretty view. The border is optional and can be disabled with the "--tight" or "--no-border" option.

It can recognize BSD ls and GNU ls (coreutils), which means it works on Linux too. Here's how it looks on Linux (using the infamous Windows 11 terminal):

1788618225324.png


And the extra candy on top of the cake is that, with a little bit more of hacking, all these filenames can be made clickable. All one needs to do is to wrap them into OSC-8 URL escape sequences in the awk code at the appropriate place, like this:

Instead of doing printf "%s", filename you would do: printf "\033]8;;file://absolute_path_to_filename\a%s\033]8;;\a", filename and the resulting display would produce clickable things.

... but as this is a "sensitive" feature (especially in SSH sessions) I haven't included here. You can easily figure out how to add it back. But TBH, the real use of such a thing would be to browse around the filesystem by issuing "cd" commands in subdirectories by clicking on the directory names. AFAIK there's no standardized protocol for issuing directory change commands, and implementing one needs a terminal emulator patch.

Enough parading around. Here's the code:

sh:
unalias ls 2>/dev/null
ls() # this code is intentionally dense, to make it easier to copy/paste in any Bourne shell's rc file.
{
    if [ ! -t 1 ] || ! printf "%s%s%s" "${LANG}" "${CHARSET}" "${MM_CHARSET}"|grep -qi UTF; then
        command ls "$@"; return "$?" # if output is not a terminal OR isn't set to Unicode, don't bother and passthrough to the original ls
    fi
    if command ls --version 2>/dev/null|grep -q 'GNU coreutils'; then
        _LSCMD='LC_ALL=C command ls -linsH --color=never --hyperlink=never --time-style="+%b %e %H:%M:%S %Y"' # GNU ls. GNU's not UNIX.
    else
        _LSCMD='unset CLICOLOR CLICOLOR_FORCE; LC_ALL=C exec command ls -linsTH' # BSD ls (FreeBSD, NetBSD, OpenBSD). That, boy, is UNIX.
    fi
    _FORMATTER="TabC" # output formatter type, among "OneL" (one line per file), "TabC" (table, columns first), "TabL", (table, lines first)
    _BORDER=1 # whether to display rounded border around directory contents. Toggle with the --no-border or --tight arguments
    _PARSE="true" # ls options will be parsed until "--" is encountered, which will toggle this variable to false
    _INOIDX=0 # index in the "ls -lins" output of the inode number field
    _BKCIDX=0 # index in the "ls -lins" output of the block count field
    for arg; do
        "${_PARSE}" && case "${arg}" in
            --) _PARSE="false";; # stop parsing for ls arguments after "--"" has been received
            --*) # long form option
                case "${arg}" in
                    --color|--color=*)         continue;; # don't pass color options
                    --context)                 command ls "$@"; return "$?";; # security context wanted: don't go further
                    --dired)                   command ls "$@"; return "$?";; # Emacs form wanted: don't go further
                    --format=commas)           command ls "$@"; return "$?";; # serialization wanted: don't go further
                    --format=long)             command ls "$@"; return "$?";; # long form wanted: don't go further
                    --format=verbose)          command ls "$@"; return "$?";; # long form wanted: don't go further
                    --full-time)               command ls "$@"; return "$?";; # long form wanted: don't go further
                    --hyperlink|--hyperlink=*) continue;; # don't pass hyperlink options
                    --inode)                   _INOIDX=1; continue;; # with inode numbers
                    --no-group)                command ls "$@"; return "$?";; # long form wanted: don't go further
                    --numeric-uid-gid)         command ls "$@"; return "$?";; # long form wanted: don't go further
                    --size)                    _BKCIDX=2; continue;; # with size in blocks
                    --zero)                    command ls "$@"; return "$?";; # NUL-byte serialization wanted: don't go further
                    --no-border*|--tight)      _BORDER=0; continue;; # don't display borders
                esac;;
            -*) # short form option
                argchars="${arg#-}" # strip leading dash
                filteredchars=""
                while [ -n "${argchars}" ]; do # evaluate these option characters one after the other
                    argchar="${argchars%"${argchars#?}"}" # pick one character from the options word
                    argchars="${argchars#?}" # chop off that character from the word for the next pass
                    case "${argchar}" in
                        g|l|n|o|Z) command ls "$@"; return "$?";; # long form wanted: don't go further
                        i) _INOIDX="1"; continue;; # with inode numbers
                        m) command ls "$@"; return "$?";; # comma-separated serialization: don't go further
                        s) _BKCIDX="2"; continue;; # with size in blocks
                        1) _FORMATTER="OneL"; continue;; # one entry per line
                        x) _FORMATTER="TabL"; continue;; # columns sorted across the page
                    esac
                    filteredchars="${filteredchars}${argchar}" # reconstruct the filtered-out options word
                done
                test -n "${filteredchars}" && arg="-${filteredchars}" || arg="";; # now if we filtered anything, substitute it to the argument evaluated
        esac
        test -n "${arg}" && _LSCMD="${_LSCMD} $(printf '%s' "${arg}"|sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/")" # quote and escape each concatenated argument
    done; unset _PARSE
    _TERMWIDTH="$(tput cols 2>/dev/null || stty size 2>/dev/null|cut -d ' ' -f 2 || { test -n "${COLUMNS}" && printf '%s' "${COLUMNS}" || printf '80'; })" # try (hard) to figure out the terminal width
    _UTFLOCALE="$(locale -a 2>/dev/null|grep -i '^C.UTF-*8')"
    eval "( ${_LSCMD}; )"|LC_CTYPE="${_UTFLOCALE}" awk -v formatter="${_FORMATTER}" -v ino_idx="${_INOIDX}" -v bkc_idx="${_BKCIDX}" -v termwidth="${_TERMWIDTH}" -v border="${_BORDER}" -v lscmd="${_LSCMD}" '
        BEGIN { longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0 } # start this awk program by initializing some stuff
        function flush(cmd, col_width, i, row_index, row_count, col_index, col_count, col_pos) { # this function is called when we reach a directory contents boundary, and at the end of the program
            if (entry_count == 0) return # nothing to flush? okay, seeya
            col_width = 2                                       # icon width
            if (longest["ino"]) col_width += 1 + longest["ino"] # space separator + inode width
            if (longest["blk"]) col_width += 1 + longest["blk"] # space separator + block count width
            col_width += 1 + longest["nam"] + 1                 # space separator + name width + space separator
            col_count = (formatter == "OneL" ? 1 : int((termwidth - 1) / ((border ? 1 : 0) + col_width))) # figure out the number of colums we can have
            if      (col_count < 1)           col_count = 1           # ensure there will be minimum 1 column
            else if (col_count > entry_count) col_count = entry_count # ensure there will be no more than necessary
            row_count = int((entry_count + col_count - 1) / col_count) # deduce the number of rows we need
            if (formatter == "TabC") # are we in column-first mode? if so, attempt to reduce the number of columns on the right if there are too many
                for (; col_count > 1; col_count--) { # as long as there are more than one column...
                    for (row_index = 0; row_index < row_count; row_index++) if (entries["" ((col_count - 1) * row_count + row_index) "|nam"] != "") break # see if any item in the last column is set
                    if (row_index < row_count) break # if at least one item is set, stop shrinking
                }
            for (row_index = 0; row_index < row_count; row_index++) { # for each row...
                if (border && (row_index == 0)) { # if we want a pretty border around each block, display the top border
                    printf "\033[90m╭"; col_pos = 1; # start drawing the top border, and remember how far we are from the terminal bounds
                    for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
                        if (col_index > 0)              { printf "┬"; col_pos++; if (col_pos == termwidth - 1) break }
                        for (i = 0; i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
                    }
                    printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╮" : "") # finish the border line unless terminal bounds are reached and drop a newline
                }
                if (border) { # when drawing a table line, start by drawing the rightmost border if we want one, to account for different character widths
                    col_pos = 1 + col_count * (1 + col_width) # figure out how far the rightmost border should be
                    if (col_pos < termwidth - 1) {
                        for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
                        printf "\033[90m│\033[0m" # only draw it if it does not go beyond the terminal bounds
                    }
                }
                for (col_index = col_count - 1; col_index >= 0; col_index--) { # draw columns backwards, to compensate for variable character widths
                    col_pos = (border ? 1 : 0) + col_index * ((border ? 1 : 0) + col_width)    # locate at the right place in the line
                    printf "\r"; for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
                    if (border) printf "\033[90m│\033[0m"; else if (col_index > 0) printf " "  # print column border, or spacer if we do not want borders, when appropriate
                    i = (formatter == "TabC" ? i = col_index * row_count + row_index : row_index * col_count + col_index) # pick the right item to display: columns first, else lines first
                    printf "%s ", entries["" i "|ico"]                                         # start by printing the icon placeholder
                    if (ino_idx > 0)   printf "%*s ", longest["ino"], entries["" i "|ino"]     # if requested, append inode number and separator
                    if (bkc_idx > 0)   printf "%*s ", longest["blk"], entries["" i "|blk"]     # if requested, append item size in blocks and separator
                    printf "\033[%sm%s\033[0m", entries["" i "|clr"], entries["" i "|nam"]     # now append the name in the right color
                }
                print "" # terminate the row with a newline
                if (border && (row_index + 1 == row_count)) { # if we want a pretty border around each block, display the bottom border
                    printf "\033[90m╰"; col_pos = 1; # start drawing the bottom border, and remember how far we are from the terminal bounds
                    for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
                        if (col_index > 0)              { printf "┴"; col_pos++; if (col_pos == termwidth - 1) break }
                        for (i = 0; i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
                    }
                    printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╯" : "") # finish the border line unless terminal bounds are reached and drop a newline
                }
            }
            delete entries; longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0
        }
        /^[^[:space:]].*:$/ { flush(); print; next } # line is a ls directory heading: flush the preceding entries (if any), then emit the heading untouched
        /^$/                { flush(); if (!border) print; next } # line is a blank line separates multiple ls operands: flush the preceding entries, and emit a blank line if borders are disabled
        /^total[[:space:]]/ {                 next } # line is a directory summary line produced by ls -l: ignore
        {
            # line is a directory entry information provided by ls -lins
            ino = blk = "" # collect the fields we want to include in our formatted output
            for (field_idx = 1; field_idx < 11; field_idx++) {
                if      (field_idx == ino_idx) ino = "" $field_idx # collect the inode field if we want it
                else if (field_idx == bkc_idx) blk = "" $field_idx # collect the size in blocks field if we want it
            }
            nam = $0; # collect the name
            for (field_idx = 0; field_idx < 11; field_idx++)
                sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) # the filename starts at field 10, so blank out everything before that
            type = substr($3, 1, 1) # isolate the entry type from the mode tag
            if (type == "l") { sub(/ -> .*/, "", nam) } # strip symlink target from name
            else if (((type == "c") || (type == "b")) && (lscmd ~ "hyperlink")) { sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) } # if using GNU coreutils, strip device nodes from their extra minor field
            # now decide about icon and color, and stuff all that in an associative array (in awk, array indices can be strings -- lemme abuse it)
            if (type == "d" && substr($3, 6, 1) == "s") { entries["" entry_count "|ico"] = "🗂️"; entries["" entry_count "|clr"] = "97" } # shared directory
            else if (type == "d")                       { entries["" entry_count "|ico"] = "📁"; entries["" entry_count "|clr"] = "97" } # directory
            else if (type == "l")                       { entries["" entry_count "|ico"] = "🔗"; entries["" entry_count "|clr"] =  "0" } # symlink
            else if (type == "p")                       { entries["" entry_count "|ico"] = "🪈"; entries["" entry_count "|clr"] =  "0" } # pipe
            else if (type == "s")                       { entries["" entry_count "|ico"] = "🔌"; entries["" entry_count "|clr"] =  "0" } # UNIX socket
            else if (type == "c")                       { entries["" entry_count "|ico"] = "📠"; entries["" entry_count "|clr"] =  "0" } # character device
            else if (type == "b")                       { entries["" entry_count "|ico"] = "💽"; entries["" entry_count "|clr"] =  "0" } # block device
            else if (type == "w")                       { entries["" entry_count "|ico"] = "🚫"; entries["" entry_count "|clr"] =  "0" } # union mount whiteout
            else if (type == "-" && $3 ~ /s/)           { entries["" entry_count "|ico"] = "☢️"; entries["" entry_count "|clr"] = "31" } # setuid executable
            else if (type == "-" && $3 ~ /x/)           { entries["" entry_count "|ico"] = "🚀"; entries["" entry_count "|clr"] = "97" } # executable
            else if (type == "-")                       { entries["" entry_count "|ico"] = "📄"; entries["" entry_count "|clr"] =  "0" } # regular file
            else                                        { entries["" entry_count "|ico"] = "❓"; entries["" entry_count "|clr"] =  "0" } # unknown sort of directory entry
            len = length(ino); if (len > longest["ino"]) longest["ino"] = len # update the longest inode length in the block
            len = length(blk); if (len > longest["blk"]) longest["blk"] = len # update the longest item size in the block
            len = length(nam); if (len > longest["nam"]) longest["nam"] = len # update the longest filename length in the block
            entries["" entry_count "|ino"] = ino # save entry inode number
            entries["" entry_count "|blk"] = blk # save entry block count
            entries["" entry_count "|nam"] = nam # save entry name
            entry_count++ # remember one item more was stacked up 
        }
        END { flush() } # flush the contents of the last directory
    '; unset _UTFLOCALE _TERMWIDTH _BORDER _BLKIDX _INOIDX _FORMATTER _LSCMD # cleanup
}
alias ls="ls --group-directories-first"

How to use : copy/paste this whole code block in your .profile, and enjoy a beautiful ls.

If you want to test it before putting it in your profile, which is understandable, just paste it in your terminal and try a few 'ls' commands.

(Constructive) comments and suggestions are welcome! -- and if you find this gimmick useful, tips to https://paypal.me/pmbaty will directly go into my weekend beer budget 😇
 
The Bourne shell has no built-in "ls" and uses FreeBSD's /bin/ls, so this option should be recognized by the FreeBSD ls. If you get this error, it means that either you're not using /bin/ls (that could be possible if on zsh 'ls' is a builtin - I admit I haven't tested this with zsh), or your /bin/ls is neither a BSD nor a CoreUtils one.

If it's a shell builtin problem, the fix should thus be to replace all occurrences of "command ls" in the script above by explicit "/bin/ls" calls. Can you try it and tell me if it does the job?

Alternatively, just chop off the last line of the script. Having directories first is, of course, a personal preference.

*edit* I just tested the script with zsh, and it works as designed. So I am led to the conclusion that you're perhaps using a BusyBox or ToyBox-based system. On these systems where the --group-directories-first option is not supported, simply remove the last line of the script (alias ls="ls --group-directories-first"). The files array could as well be reordered by awk, anyway.
 
Clickable version :
simplescreenrecorder-2026-09-06_01.09.05.gif


For this use the following code to generate OCS-8 escape sequences:

sh:
unalias ls 2>/dev/null
ls() # this code is intentionally dense, to make it easier to copy/paste in any Bourne shell's rc file.
{
    if [ ! -t 1 ] || ! printf "%s%s%s" "${LANG}" "${CHARSET}" "${MM_CHARSET}"|grep -qi UTF; then
        command ls "$@"; return "$?" # if output is not a terminal OR isn't set to Unicode, don't bother and passthrough to the original ls
    fi
    if command ls --version 2>/dev/null|grep -q 'GNU coreutils'; then
        _LSCMD='LC_ALL=C command ls -linsH --color=never --hyperlink=never --time-style="+%b %e %H:%M:%S %Y"' # GNU ls. GNU's not UNIX.
    else
        _LSCMD='unset CLICOLOR CLICOLOR_FORCE; LC_ALL=C exec command ls -linsTH' # BSD ls (FreeBSD, NetBSD, OpenBSD). That, boy, is UNIX.
    fi
    _FORMATTER="TabC" # output formatter type, among "OneL" (one line per file), "TabC" (table, columns first), "TabL", (table, lines first)
    _BORDER=1 # whether to display rounded border around directory contents. Toggle with the --no-border or --tight arguments
    _PARSE="true" # ls options will be parsed until "--" is encountered, which will toggle this variable to false
    _INOIDX=0 # index in the "ls -lins" output of the inode number field
    _BKCIDX=0 # index in the "ls -lins" output of the block count field
    _CWD="$(pwd)" # set to cwd when we detect that we want to list the current working directory, empty string otherwise
    for arg; do
        "${_PARSE}" && case "${arg}" in
            --) _PARSE="false";; # stop parsing for ls arguments after "--"" has been received
            --*) # long form option
                case "${arg}" in
                    --color|--color=*)         continue;; # don't pass color options
                    --context)                 command ls "$@"; return "$?";; # security context wanted: don't go further
                    --dired)                   command ls "$@"; return "$?";; # Emacs form wanted: don't go further
                    --format=commas)           command ls "$@"; return "$?";; # serialization wanted: don't go further
                    --format=long)             command ls "$@"; return "$?";; # long form wanted: don't go further
                    --format=verbose)          command ls "$@"; return "$?";; # long form wanted: don't go further
                    --full-time)               command ls "$@"; return "$?";; # long form wanted: don't go further
                    --hyperlink|--hyperlink=*) continue;; # don't pass hyperlink options
                    --inode)                   _INOIDX=1; continue;; # with inode numbers
                    --no-group)                command ls "$@"; return "$?";; # long form wanted: don't go further
                    --numeric-uid-gid)         command ls "$@"; return "$?";; # long form wanted: don't go further
                    --size)                    _BKCIDX=2; continue;; # with size in blocks
                    --zero)                    command ls "$@"; return "$?";; # NUL-byte serialization wanted: don't go further
                    --no-border*|--tight)      _BORDER=0; continue;; # don't display borders
                esac;;
            -*) # short form option
                argchars="${arg#-}" # strip leading dash
                filteredchars=""
                while [ -n "${argchars}" ]; do # evaluate these option characters one after the other
                    argchar="${argchars%"${argchars#?}"}" # pick one character from the options word
                    argchars="${argchars#?}" # chop off that character from the word for the next pass
                    case "${argchar}" in
                        g|l|n|o|Z) command ls "$@"; return "$?";; # long form wanted: don't go further
                        i) _INOIDX="1"; continue;; # with inode numbers
                        m) command ls "$@"; return "$?";; # comma-separated serialization: don't go further
                        s) _BKCIDX="2"; continue;; # with size in blocks
                        1) _FORMATTER="OneL"; continue;; # one entry per line
                        x) _FORMATTER="TabL"; continue;; # columns sorted across the page
                    esac
                    filteredchars="${filteredchars}${argchar}" # reconstruct the filtered-out options word
                done
                test -n "${filteredchars}" && arg="-${filteredchars}" || arg="";; # now if we filtered anything, substitute it to the argument evaluated
            *) # not an option
                test -e "${arg}" && _CWD="";; # if it's an existing filesystem object, it's a ls target
        esac
        test -n "${arg}" && _LSCMD="${_LSCMD} $(printf '%s' "${arg}"|sed "s/'/'\\\\''/g; 1s/^/'/; \$s/\$/'/")" # quote and escape each concatenated argument
    done; unset _PARSE
    _TERMWIDTH="$(tput cols 2>/dev/null || stty size 2>/dev/null|cut -d ' ' -f 2 || { test -n "${COLUMNS}" && printf '%s' "${COLUMNS}" || printf '80'; })" # try (hard) to figure out the terminal width
    _UTFLOCALE="$(locale -a 2>/dev/null|grep -i '^C.UTF-*8')"
    eval "( ${_LSCMD}; )"|LC_CTYPE="${_UTFLOCALE}" awk -v formatter="${_FORMATTER}" -v ino_idx="${_INOIDX}" -v bkc_idx="${_BKCIDX}" -v termwidth="${_TERMWIDTH}" -v border="${_BORDER}" -v cwd="${_CWD}" -v lscmd="${_LSCMD}" '
        BEGIN { longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0 } # start this awk program by initializing some stuff
        function flush(col_width, i, row_index, row_count, col_index, col_count, col_pos, is_dir) { # this function is called when we reach a directory contents boundary, and at the end of the program
            if (entry_count == 0) return # nothing to flush? okay, seeya
            col_width = 2                                       # icon width
            if (longest["ino"]) col_width += 1 + longest["ino"] # space separator + inode width
            if (longest["blk"]) col_width += 1 + longest["blk"] # space separator + block count width
            col_width += 1 + longest["nam"] + 1                 # space separator + name width + space separator
            col_count = (formatter == "OneL" ? 1 : int((termwidth - 1) / ((border ? 1 : 0) + col_width))) # figure out the number of colums we can have
            if      (col_count < 1)           col_count = 1           # ensure there will be minimum 1 column
            else if (col_count > entry_count) col_count = entry_count # ensure there will be no more than necessary
            row_count = int((entry_count + col_count - 1) / col_count) # deduce the number of rows we need
            if (formatter == "TabC") # are we in column-first mode? if so, attempt to reduce the number of columns on the right if there are too many
                for (; col_count > 1; col_count--) { # as long as there are more than one column...
                    for (row_index = 0; row_index < row_count; row_index++) if (entries["" ((col_count - 1) * row_count + row_index) "|nam"] != "") break # see if any item in the last column is set
                    if (row_index < row_count) break # if at least one item is set, stop shrinking
                }
            for (row_index = 0; row_index < row_count; row_index++) { # for each row...
                if (border && (row_index == 0)) { # if we want a pretty border around each block, display the top border
                    printf "\033[90m╭"; col_pos = 1; # start drawing the top border, and remember how far we are from the terminal bounds
                    if ((cwd != "") && (cwd != "/")) { printf "[\033]8;;chdir://" cwd "/..\a\033[97m..\033[90m\033]8;;\a]"; col_pos += 4 }
                    for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
                        if (col_index > 0)                                               { printf "┬"; col_pos++; if (col_pos == termwidth - 1) break }
                        for (i = (col_index == 0 ? col_pos - 1 : 0); i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
                    }
                    printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╮" : "") # finish the border line unless terminal bounds are reached and drop a newline
                }
                if (border) { # when drawing a table line, start by drawing the rightmost border if we want one, to account for different character widths
                    col_pos = 1 + col_count * (1 + col_width) # figure out how far the rightmost border should be
                    if (col_pos < termwidth - 1) {
                        for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
                        printf "\033[90m│\033[0m" # only draw it if it does not go beyond the terminal bounds
                    }
                }
                for (col_index = col_count - 1; col_index >= 0; col_index--) { # draw columns backwards, to compensate for variable character widths
                    col_pos = (border ? 1 : 0) + col_index * ((border ? 1 : 0) + col_width)    # locate at the right place in the line
                    printf "\r"; for (i = 0; i < col_pos - 1; i++) printf " " # NOTE: the awk printf "%*s" handling of UTF-8 codepoints vs. bytes is implementation-specific, thus unreliable!
                    if (border) printf "\033[90m│\033[0m"; else if (col_index > 0) printf " "  # print column border, or spacer if we do not want borders, when appropriate
                    i = (formatter == "TabC" ? col_index * row_count + row_index : row_index * col_count + col_index) # pick the right item to display: columns first, else lines first
                    printf "%s ", entries["" i "|ico"]                                         # start by printing the icon placeholder
                    if (ino_idx > 0)   printf "%*s ", longest["ino"], entries["" i "|ino"]     # if requested, append inode number and separator
                    if (bkc_idx > 0)   printf "%*s ", longest["blk"], entries["" i "|blk"]     # if requested, append item size in blocks and separator
                    printf "\033[%sm", entries["" i "|clr"]                                    # now append the name in the right color
                    is_dir = ((entries["" i "|ico"] == "🗂️") || (entries["" i "|ico"] == "📁"))
                    if ((cwd != "") && is_dir) printf "\033]8;;chdir://%s\a%s\033]8;;\a", cwd (cwd == "/" ? "" : "/") entries["" i "|nam"], entries["" i "|nam"]
                    else if ((cwd != "") && (ENVIRON["SSH_CONNECTION"] == "")) printf "\033]8;;file://%s\a%s\033]8;;\a", cwd (cwd == "/" ? "" : "/") entries["" i "|nam"], entries["" i "|nam"]
                    else printf "%s", entries["" i "|nam"]
                    printf "\033[0m"
                }
                print "" # terminate the row with a newline
                if (border && (row_index + 1 == row_count)) { # if we want a pretty border around each block, display the bottom border
                    printf "\033[90m╰"; col_pos = 1; # start drawing the bottom border, and remember how far we are from the terminal bounds
                    if ((cwd != "") && (cwd != "/")) { printf "[\033]8;;chdir://" cwd "/..\a\033[97m..\033[90m\033]8;;\a]"; col_pos += 4 }
                    for (col_index = 0; col_index < col_count; col_index++) { # draw the top border line until the terminal bounds are reached
                        if (col_index > 0)                                               { printf "┴"; col_pos++; if (col_pos == termwidth - 1) break }
                        for (i = (col_index == 0 ? col_pos - 1 : 0); i < col_width; i++) { printf "─"; col_pos++; if (col_pos == termwidth - 1) break }
                    }
                    printf "%s\033[0m\n", (col_pos < termwidth - 1 ? "╯" : "") # finish the border line unless terminal bounds are reached and drop a newline
                }
            }
            delete entries; longest["ino"] = longest["blk"] = longest["nam"] = entry_count = 0
        }
        /^[^[:space:]].*:$/ { flush(); print; next } # line is a ls directory heading: flush the preceding entries (if any), then emit the heading untouched
        /^$/                { flush(); if (!border) print; next } # line is a blank line separates multiple ls operands: flush the preceding entries, and emit a blank line if borders are disabled
        /^total[[:space:]]/ {                 next } # line is a directory summary line produced by ls -l: ignore
        {
            # line is a directory entry information provided by ls -lins
            ino = blk = "" # collect the fields we want to include in our formatted output
            for (field_idx = 1; field_idx < 11; field_idx++) {
                if      (field_idx == ino_idx) ino = "" $field_idx # collect the inode field if we want it
                else if (field_idx == bkc_idx) blk = "" $field_idx # collect the size in blocks field if we want it
            }
            nam = $0; # collect the name
            for (field_idx = 0; field_idx < 11; field_idx++)
                sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) # the filename starts at field 10, so blank out everything before that
            type = substr($3, 1, 1) # isolate the entry type from the mode tag
            if (type == "l") { sub(/ -> .*/, "", nam) } # strip symlink target from name
            else if (((type == "c") || (type == "b")) && (lscmd ~ "hyperlink")) { sub(/^[[:space:]]*[^[:space:]]+[[:space:]]*/, "", nam) } # if using GNU coreutils, strip device nodes from their extra minor field
            # now decide about icon and color, and stuff all that in an associative array (in awk, array indices can be strings -- lemme abuse it)
            if (type == "d" && substr($3, 6, 1) == "s") { entries["" entry_count "|ico"] = "🗂️"; entries["" entry_count "|clr"] = "97" } # shared directory
            else if (type == "d")                       { entries["" entry_count "|ico"] = "📁"; entries["" entry_count "|clr"] = "97" } # directory
            else if (type == "l")                       { entries["" entry_count "|ico"] = "🔗"; entries["" entry_count "|clr"] =  "0" } # symlink
            else if (type == "p")                       { entries["" entry_count "|ico"] = "🪈"; entries["" entry_count "|clr"] =  "0" } # pipe
            else if (type == "s")                       { entries["" entry_count "|ico"] = "🔌"; entries["" entry_count "|clr"] =  "0" } # UNIX socket
            else if (type == "c")                       { entries["" entry_count "|ico"] = "📠"; entries["" entry_count "|clr"] =  "0" } # character device
            else if (type == "b")                       { entries["" entry_count "|ico"] = "💽"; entries["" entry_count "|clr"] =  "0" } # block device
            else if (type == "w")                       { entries["" entry_count "|ico"] = "🚫"; entries["" entry_count "|clr"] =  "0" } # union mount whiteout
            else if (type == "-" && $3 ~ /s/)           { entries["" entry_count "|ico"] = "☢️"; entries["" entry_count "|clr"] = "31" } # setuid executable
            else if (type == "-" && $3 ~ /x/)           { entries["" entry_count "|ico"] = "🚀"; entries["" entry_count "|clr"] = "97" } # executable
            else if (type == "-")                       { entries["" entry_count "|ico"] = "📄"; entries["" entry_count "|clr"] =  "0" } # regular file
            else                                        { entries["" entry_count "|ico"] = "❓"; entries["" entry_count "|clr"] =  "0" } # unknown sort of directory entry
            len = length(ino); if (len > longest["ino"]) longest["ino"] = len # update the longest inode length in the block
            len = length(blk); if (len > longest["blk"]) longest["blk"] = len # update the longest item size in the block
            len = length(nam); if (len > longest["nam"]) longest["nam"] = len # update the longest filename length in the block
            entries["" entry_count "|ino"] = ino # save entry inode number
            entries["" entry_count "|blk"] = blk # save entry block count
            entries["" entry_count "|nam"] = nam # save entry name
            entry_count++ # remember one item more was stacked up 
        }
        END { flush() } # flush the contents of the last directory
    '; unset _CWD _UTFLOCALE _TERMWIDTH _BORDER _BLKIDX _INOIDX _FORMATTER _LSCMD # cleanup
}
alias ls="ls --group-directories-first"
unalias cd 2>/dev/null
cd() # also alias 'cd' to automatically display the contents of the directory we're browsing to
{
    if [ ! -t 1 ]; then
        command cd "$@" && return "$?" || return "$?" # if output is not a terminal, don't bother and passthrough to the original cd
    fi
    if command cd "$@"; then _RET="$?"; ls -A; else _RET="$?"; fi; return "${_RET}" # on each interactive "cd", issue an immediate "ls"
}

You also need to extend a bit the /usr/ports/x11/xfce4-terminal/files/patch-terminal_terminal-widget.c patch :

C:
--- terminal/terminal-widget.c.orig 2026-09-05 20:40:56.081186000 +0200
+++ terminal/terminal-widget.c  2026-09-05 20:41:20.429638000 +0200
@@ -55,6 +55,7 @@
 typedef enum
 {
   PATTERN_TYPE_NONE,
+  PATTERN_TYPE_CHDIR, // clickable 'ls' dirs -- Pierre-Marie Baty <pm@pmbaty.com>
   PATTERN_TYPE_FULL_HTTP,
   PATTERN_TYPE_HTTP,
   PATTERN_TYPE_EMAIL,
@@ -609,8 +610,18 @@
     }
 
   if (!intercept)
-    handled = (*GTK_WIDGET_CLASS (terminal_widget_parent_class)->button_press_event) (widget, event);
+    {
+      GtkSettings *settings = gtk_settings_get_default ();
+      gboolean primary_paste_enabled;
+      g_object_get (settings, "gtk-enable-primary-paste", &primary_paste_enabled, NULL);
 
+      /* don't let vte handle primary paste; we want to do it ourselves later, especially
+       * to trigger the unsafe paste dialog if necessary */
+      g_object_set (settings, "gtk-enable-primary-paste", FALSE, NULL);
+      handled = (*GTK_WIDGET_CLASS (terminal_widget_parent_class)->button_press_event) (widget, event);
+      g_object_set (settings, "gtk-enable-primary-paste", primary_paste_enabled, NULL);
+    }
+
   if (event->button == 2 && event->type == GDK_BUTTON_PRESS)
     {
       /* if handled is true, it means the VteTerminal's handler either already
@@ -864,10 +875,19 @@
   GtkWindow *window = GTK_WINDOW (gtk_widget_get_toplevel (GTK_WIDGET (widget)));
   GError *error = NULL;
   gchar *uri;
+  gchar *quoted, *command; // clickable 'ls' dirs -- Pierre-Marie Baty <pm@pmbaty.com>
 
   /* handle the pattern type */
   switch (type)
     {
+    case PATTERN_TYPE_CHDIR: // clickable 'ls' dirs -- Pierre-Marie Baty <pm@pmbaty.com>
+      quoted = g_shell_quote (&wlink[8]); // skip "chdir://" and escape shell quotes to prevent command injection
+      command = g_strdup_printf ("cd -- %s\n", quoted); // construct a heap-allocated cd command string
+      vte_terminal_feed_child (VTE_TERMINAL (widget), command, strlen (command)); // issue it to xfce4-terminal
+      g_free (command); // cleanup
+      g_free (quoted); // cleanup
+      return; // do NOT passthrough that URI to the default URI handler
+
     case PATTERN_TYPE_FULL_HTTP:
     case PATTERN_TYPE_FILE:
       uri = g_strdup (wlink);
@@ -1079,6 +1099,13 @@
     {
       gint rc;
 
+      if (g_str_has_prefix (uri, "chdir://")) // clickable 'ls' dirs -- Pierre-Marie Baty <pm@pmbaty.com>
+        {
+          result.uri = uri; // any OCS-8 URI that begins with "chdir://" is to be passed back to the terminal as a cd argument
+          result.type = PATTERN_TYPE_CHDIR;
+          return result;
+        }
+
       for (i = 0; i < G_N_ELEMENTS (widget->regex_pcre); i++)
         {
           if (widget->regex_pcre[i] == NULL)
Apply, then make reinstall. And restart xfce4-terminal.

DISCLAIMER: automating terminal commands (including "cd") contains an inherent security risk. Proceed at own risk.

Ctrl+Click to follow links in your terminal. Happy browsing 😊
 
The Bourne shell has no built-in "ls" and uses FreeBSD's /bin/ls, so this option should be recognized by the FreeBSD ls. If you get this error, it means that either you're not using /bin/ls (that could be possible if on zsh 'ls' is a builtin - I admit I haven't tested this with zsh), or your /bin/ls is neither a BSD nor a CoreUtils one.

If it's a shell builtin problem, the fix should thus be to replace all occurrences of "command ls" in the script above by explicit "/bin/ls" calls. Can you try it and tell me if it does the job?

Alternatively, just chop off the last line of the script. Having directories first is, of course, a personal preference.

*edit* I just tested the script with zsh, and it works as designed. So I am led to the conclusion that you're perhaps using a BusyBox or ToyBox-based system. On these systems where the --group-directories-first option is not supported, simply remove the last line of the script (alias ls="ls --group-directories-first"). The files array could as well be reordered by awk, anyway.
It's a generic FreeBSD 14 installation, this option is not available
sh:
% freebsd-version
  14.4-RELEASE-p9
% /bin/ls --group-directories-first
  ls: unrecognized option `--group-directories-first'
  usage: ls [-ABCFGHILPRSTUWZabcdfghiklmnopqrstuvwxy1,] [--color=when] [-D format] [file ...]
It is a recent option, currently available only on FreeBSD 15.
 
Back
Top