OSC 8 hyperlinks in custom statusline work in IDE terminals but not in real terminal emulators (Konsole)

Status Closed — not planned
Reported on v2.1.2
Maintainer reply None cached
Activity 14 comments · opened Feb 17, 2026 · closed Jun 27, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Custom statusline commands that output OSC 8 hyperlinks render correctly in IDE integrated terminals (VS Code, Cursor, etc.) but not in real terminal emulators like Konsole.

This is a follow-up to #21586, which was fixed in or before v2.1.42 for VSCode IDE terminals. The fix does not extend to standalone terminal emulators — the same statusline script that produces a clickable link in VS Code produces plain text in Konsole. Konsole has had buggy OSC behaviour in older versions, but this reproduction uses Konsole 24.12.3 (KDE Plasma 6 / Ubuntu 25.04), which has full OSC 8 support.

In v2.0.76, the OSC 8 link works correctly in both IDE terminals and Konsole. The regression was introduced somewhere between v2.0.76 and the current version.

What Should Happen?

The statusline text containing OSC 8 hyperlink sequences should render as underlined, clickable text in any terminal that supports OSC 8 — not just IDE integrated terminals. Konsole has full OSC 8 support and renders OSC 8 links correctly in all other contexts.

Error Messages/Logs

No error messages. The text simply renders as plain text instead of a hyperlink in Konsole, while the identical output renders correctly in VS Code's integrated terminal.

Steps to Reproduce

A full Dockerized reproduction environment is available: https://github.com/Nubebuster/statusline-regression which automates all the steps below that don't require user interaction.

Quick reproduction

  1. Create a minimal statusline script (statusline.sh):
#!/bin/bash
printf '\e]8;;toclipboard://test-uuid\e\\uuid\e]8;;\e\\'
  1. Configure Claude Code to use it (~/.claude/settings.json):
{
  "statusLine": {
    "type": "command",
    "command": "/path/to/statusline.sh",
    "padding": 0
  }
}
  1. Run claude in a VS Code integrated terminal → the "uuid" text in the statusline is underlined and clickable. ✅
  1. Run claude in Konsole (or another real terminal emulator with OSC 8 support) → the "uuid" text is plain, not clickable. ❌
  1. Repeat step 4 with Claude Code v2.0.76 → the link works correctly in Konsole. ✅

Dockerized reproduction

git clone https://github.com/Nubebuster/statusline-regression.git
cd statusline-regression
chmod +x run.sh
./run.sh    # select version to test

This opens a KDE Plasma desktop (via x11docker) with both VS Code and Konsole. Full instructions appear in the terminal. Compare behavior between v2.0.76 and latest.

Claude Model

N/A

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.2

Claude Code Version

2.1.44
Update, still present in: 2.1.79

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Konsole (KDE)

Additional Information

Relationship to #21586

Issue #21586 reported that OSC 8 links in custom statuslines stopped working entirely in v2.1.3. That issue was closed after a fix in v2.1.42 restored functionality in IDE terminals. However, the fix appears to only work for terminals where the IDE handles escape sequence rendering (VS Code, Cursor, etc.). Real terminal emulators that implement OSC 8 natively (Konsole, iTerm2, etc.) are still affected.

Why this matters

IDE terminals are not the only environment where Claude Code is used. Konsole, iTerm2, Alacritty, and other terminal emulators all support OSC 8 and are common development environments. Claude Code itself uses OSC 8 for file path links, so terminal support is already assumed.

Technical context

The OSC 8 hyperlink format is a standard terminal escape sequence:

\e]8;;URL\e\\VISIBLE_TEXT\e]8;;\e\\

The statusline script output is byte-identical across versions — the regression is in how Claude Code passes the escape sequence through to the terminal, not in the script itself.

View original on GitHub ↗

14 Comments

github-actions[bot] · 6 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/23438
  2. https://github.com/anthropics/claude-code/issues/21586
  3. https://github.com/anthropics/claude-code/issues/14011

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

sai-kaneko-31 · 6 months ago

Confirming this also affects Windows Terminal (v1.23) on WSL2 (Ubuntu).

Same behavior:

  • VS Code integrated terminal: OSC 8 links clickable ✅
  • Windows Terminal: plain text, not clickable ❌
  • Raw echo -e '\e]8;;URL\atext\e]8;;\a' in Windows Terminal (outside Claude Code): clickable ✅

Tested with both cc-statusline (custom, BEL terminator) and him0/claude-code-statusline — neither produces clickable links in Windows Terminal.

Claude Code version: latest (as of 2026-02-23)

ilepn · 6 months ago

Ran into this on Windows Terminal (Windows 11) while building a custom statusline script. Spent time implementing OSC 8 file:// links for the working directory and https:// links for git branch→repo URLs — the escape sequences are byte-perfect (verified with xxd), ANSI SGR colors render fine, but OSC 8 links are completely inert. No clickable behavior, no underline, nothing.

Would be helpful if the Claude Code docs for custom statuslines noted this limitation upfront. Right now the statusline docs show OSC 8 as a supported feature, which sends you down a rabbit hole of implementing something that doesn't work in standalone terminals. A quick screening of this repo's issues before developing would have saved the effort — but ideally the feature would just work as documented.

Platform: Windows 11, Windows Terminal 1.22, Claude Code 2.1.x, Git Bash shell.

NubeBuster · 6 months ago

/do-not-autoclose

scubashack808 · 5 months ago

Adding another data point: this also affects Ghostty 1.3.0 on macOS (Sequoia 26.3.1, M5 MacBook Air).

File paths in Claude Code output (e.g. src/components/App.tsx:42) render as plain text. Cmd+click does nothing because the OSC 8 sequences aren't reaching the terminal. URLs work fine via Ghostty's built-in URL detection, but the file path hyperlinks that Claude Code is supposed to emit are completely absent.

This was working on my previous machine with an older Claude Code version. Confirmed on Claude Code 2.1.79.

This is a significant productivity hit for anyone using Claude Code in a standalone terminal. File paths are output constantly and having to manually copy-paste them or navigate to find them defeats the purpose.

NubeBuster · 5 months ago

This is still reproduced in this x11docker isolation test:

https://github.com/Nubebuster/statusline-regression

Running printf '\e]8;;toclipboard://test-uuid\e\\uuid\e]8;;\e\\\n' in a Konsole window in that KDE VM produces a clickable OSC8 link. Claude Code does not.

Issue still present in 2.1.79 (Claude Code)

NubeBuster · 5 months ago

Root cause analysis:

Claude Code uses ink (React-based terminal renderer) which re-renders all output through its own pipeline. Since ~v2.1.3, ink strips OSC 8 hyperlink sequences during this re-rendering — it interprets the visible text but discards the \e]8;;url\e\\...\e]8;;\e\\ wrapping.

The fix in v2.1.42 (closing #21586) likely added an IDE-specific code path that preserves or restores OSC 8 for integrated terminals (checking TERM_PROGRAM, VSCODE_PID, etc.), rather than fixing the underlying stripping in ink's rendering pipeline. Real terminal emulators (Konsole, iTerm2, Ghostty, Windows Terminal) don't match those checks, so they remain broken.

Evidence: ink re-generates its own ANSI SGR codes around the visible text — the escape sequences in the output are not the same bytes the statusline command produced, they're ink's own rendering. This confirms ink is fully re-processing the content rather than passing it through. OSC 8 gets lost in that re-processing for non-IDE terminals.

A proper fix would be to preserve OSC 8 passthrough in ink's rendering for all terminal types, not just IDE-detected ones.

NubeBuster · 5 months ago

Workaround: PTY wrapper to re-inject OSC 8 links in custom statuslines

A Python PTY wrapper that sits between the terminal and claude, intercepts output, and re-injects the OSC 8 sequences that Claude Code's Ink renderer strips. IDE terminals (VS Code, Cursor, Antigravity) are detected and skipped since they handle OSC 8 natively.

The statusline script saves its raw output (with OSC 8 intact) to a temp file. The wrapper reads that file to learn the current (visible_text, url) pairs, then patches them back into the output stream.

Note: This workaround only restores OSC 8 links in custom statuslines. Claude Code's built-in file path hyperlinks (e.g. src/components/App.tsx:42 in conversation output) are also stripped by the same Ink rendering issue but cannot be restored from outside — that requires an upstream fix.

<details>
<summary><strong>claude-osc8-wrapper</strong> (click to expand)</summary>

Save as claude-osc8-wrapper, make executable, and set up as shown below.

#!/usr/bin/env python3
"""PTY wrapper that re-injects OSC 8 hyperlinks stripped by Claude Code.

Claude Code's Ink renderer strips OSC 8 hyperlink sequences from statusline
output in real terminal emulators (Konsole, iTerm2, Ghostty, Windows Terminal).
This wrapper intercepts output through a PTY and re-injects the links by
reading the raw statusline output saved to a temp file.

IDE terminals (VS Code, Cursor) handle OSC 8 natively, so injection is skipped.

Setup:
    chmod +x claude-osc8-wrapper
    alias claude='claude-osc8-wrapper'

Statusline integration:
    Your statusline script must save its raw output (with OSC 8 intact) to
    /tmp/claude-statusline-raw-$CLAUDE_WRAPPER_PID. See the comment at the
    bottom of this file for the 3 lines to add.
"""
import errno
import fcntl
import os
import pty
import re
import select
import signal
import sys
import termios
import tty


def _in_ide_terminal():
    """IDE terminals handle OSC 8 natively; skip injection there."""
    tp = os.environ.get("TERM_PROGRAM", "").lower()
    if tp in ("vscode", "cursor", "antigravity"):
        return True
    return bool(os.environ.get("VSCODE_PID") or os.environ.get("VSCODE_CLI"))


class StatuslineInjector:
    """Re-inject OSC 8 links stripped by Claude Code's Ink renderer.

    Claude's renderer re-generates its own ANSI codes, so byte-level line
    matching fails. Instead we parse the raw statusline output (saved by the
    statusline script) to extract (visible_text, url) pairs, then search for
    each visible_text in the output stream and wrap it with OSC 8.
    """

    _OSC8_RE = re.compile(
        rb'\x1b\]8;;([^\x1b]+)\x1b\\(.*?)\x1b\]8;;\x1b\\', re.DOTALL
    )
    _ANSI_RE = re.compile(rb'\x1b\[[0-9;]*m|\x1b\(.')

    def __init__(self, raw_file):
        self._raw_file = raw_file
        self._last_mtime = 0
        self._links = []

    def _refresh(self):
        """Reload link table if the raw file has been updated."""
        try:
            mtime = os.stat(self._raw_file).st_mtime_ns
        except OSError:
            return
        if mtime == self._last_mtime:
            return
        self._last_mtime = mtime

        try:
            with open(self._raw_file, "rb") as f:
                raw = f.read()
        except OSError:
            return

        self._links = []
        for m in self._OSC8_RE.finditer(raw):
            url = m.group(1)
            visible = self._ANSI_RE.sub(b"", m.group(2))
            if len(visible) >= 4:
                self._links.append((
                    visible,
                    b'\x1b]8;;' + url + b'\x1b\\',
                    b'\x1b]8;;\x1b\\',
                ))
        self._links.sort(key=lambda x: len(x[0]), reverse=True)

    def _inside_osc(self, data, pos):
        """Return True if pos falls inside an open OSC sequence."""
        lookback = data[max(0, pos - 300):pos]
        last_osc = lookback.rfind(b'\x1b]')
        if last_osc == -1:
            return False
        after = lookback[last_osc:]
        return b'\x07' not in after and b'\x1b\\' not in after

    def process(self, data):
        """Wrap each known visible link text with its OSC 8 sequences."""
        self._refresh()
        for visible, osc8_open, osc8_close in self._links:
            pos = data.find(visible)
            if pos == -1 or self._inside_osc(data, pos):
                continue
            after = pos + len(visible)
            data = data[:pos] + osc8_open + visible + osc8_close + data[after:]
        return data


def main():
    stdin_fd = sys.stdin.fileno()
    stdout_fd = sys.stdout.fileno()

    os.environ["CLAUDE_WRAPPER_PID"] = str(os.getpid())
    raw_file = f"/tmp/claude-statusline-raw-{os.getpid()}"

    # Skip PTY proxy when stdout is not a TTY (piped output)
    if not os.isatty(stdout_fd):
        os.execvp("claude", ["claude"] + sys.argv[1:])

    pid, master_fd = pty.fork()

    if pid == 0:
        os.execvp("claude", ["claude"] + sys.argv[1:])
        sys.exit(1)

    # Parent: proxy I/O, injecting OSC 8 on real terminals
    injector = StatuslineInjector(raw_file) if not _in_ide_terminal() else None

    old_settings = tty.tcgetattr(stdin_fd)
    try:
        tty.setraw(stdin_fd)

        # Forward terminal resize to child
        def handle_winch(signum, frame):
            buf = fcntl.ioctl(stdout_fd, termios.TIOCGWINSZ, b'\x00' * 8)
            fcntl.ioctl(master_fd, termios.TIOCSWINSZ, buf)
            os.kill(pid, signal.SIGWINCH)

        signal.signal(signal.SIGWINCH, handle_winch)
        handle_winch(None, None)

        # Detect child suspension (Claude Code handles Ctrl+Z internally).
        # In raw mode, Ctrl+Z (\x1a) passes through to the child as a byte —
        # no SIGTSTP is generated for the wrapper. We watch for the child
        # stopping via SIGCHLD, then suspend ourselves to match.
        child_exit_status = [None]

        def handle_chld(signum, frame):
            try:
                wpid, status = os.waitpid(pid, os.WUNTRACED | os.WNOHANG)
            except ChildProcessError:
                return
            if wpid != pid:
                return
            if os.WIFSTOPPED(status):
                tty.tcsetattr(stdin_fd, tty.TCSAFLUSH, old_settings)
                signal.signal(signal.SIGTSTP, signal.SIG_DFL)
                os.kill(os.getpid(), signal.SIGTSTP)
            elif os.WIFEXITED(status) or os.WIFSIGNALED(status):
                child_exit_status[0] = status

        def handle_cont(signum, frame):
            signal.signal(signal.SIGTSTP, signal.SIG_DFL)
            signal.signal(signal.SIGCHLD, handle_chld)
            while True:
                try:
                    tty.setraw(stdin_fd)
                    break
                except termios.error as e:
                    if e.args[0] == errno.EINTR:
                        continue
                    raise
            os.kill(pid, signal.SIGCONT)
            handle_winch(None, None)

        signal.signal(signal.SIGCHLD, handle_chld)
        signal.signal(signal.SIGCONT, handle_cont)

        while True:
            try:
                rlist, _, _ = select.select([master_fd, stdin_fd], [], [])
            except (select.error, OSError) as e:
                if getattr(e, 'errno', None) == errno.EINTR:
                    continue
                break

            if master_fd in rlist:
                try:
                    data = os.read(master_fd, 4096)
                except OSError:
                    break
                if not data:
                    break
                if injector:
                    data = injector.process(data)
                os.write(stdout_fd, data)

            if stdin_fd in rlist:
                try:
                    data = os.read(stdin_fd, 4096)
                except OSError:
                    break
                if not data:
                    break
                os.write(master_fd, data)

    finally:
        tty.tcsetattr(stdin_fd, tty.TCSAFLUSH, old_settings)
        try:
            os.unlink(raw_file)
        except OSError:
            pass

    if child_exit_status[0] is not None:
        status = child_exit_status[0]
    else:
        _, status = os.waitpid(pid, 0)
    sys.exit(os.waitstatus_to_exitcode(status))


if __name__ == '__main__':
    main()

# ---------------------------------------------------------------------------
# Statusline script integration (add to the end of your statusline script):
#
#   if [[ -n ${CLAUDE_WRAPPER_PID:-} ]]; then
#       printf "%s\n" "$output" > "/tmp/claude-statusline-raw-${CLAUDE_WRAPPER_PID}"
#   fi
#
# $output = your complete rendered statusline string with OSC 8 sequences.
# ---------------------------------------------------------------------------

</details>

Statusline script change

Add this at the end of your statusline script, after building the final rendered output:

# Save raw output (OSC 8 intact) for the wrapper to re-inject
if [[ -n ${CLAUDE_WRAPPER_PID:-} ]]; then
    printf "%s\n" "$output" > "/tmp/claude-statusline-raw-${CLAUDE_WRAPPER_PID}"
fi

$output should be the complete rendered statusline string with OSC 8 sequences still present.

Setup (Ubuntu/Linux)

# Save the wrapper script
chmod +x claude-osc8-wrapper
sudo mv claude-osc8-wrapper /usr/local/bin/

# Add a shell function to your ~/.bashrc (or ~/.zshrc):
claude() {
  claude-osc8-wrapper "$@"
}

# Reload
source ~/.bashrc

This overrides the claude command with the wrapper. The wrapper calls the real claude binary via execvp which finds it in $PATH (typically ~/.local/bin/claude). When stdout is piped (non-interactive), the wrapper skips the PTY proxy and execs claude directly.

vlechemin · 4 months ago

This worked for me: ~/.claude/settings.json :

  {
    "env": {
      "FORCE_HYPERLINK": "1"
    }
  }
mahidalhan · 3 months ago

Repro: CLAUDE_CODE_NO_FLICKER=1 is the specific stripper (Ghostty + Warp, macOS)

Adding a narrower repro and isolation that may help upstream — FORCE_HYPERLINK=1 is NOT sufficient on its own; the relevant lever is CLAUDE_CODE_NO_FLICKER.

Environment

  • Claude Code 2.1.138
  • Ghostty 1.3.1 (also reproduced on Warp Terminal)
  • macOS Darwin 25.2.0
  • Standalone terminal (not an IDE-attached terminal)

Isolation matrix

| Env vars set on the claude process | OSC 8 click |
|---|---|
| CLAUDE_CODE_NO_FLICKER=1, FORCE_HYPERLINK=1 | ❌ broken — link rendered, click does nothing |
| FORCE_HYPERLINK=1 (NO_FLICKER unset) | ✅ works — cmd+click opens the file |

Env presence was verified directly on the running claude PID via ps -E -p <pid>, not assumed from shell exports.

Repro

# 1. Confirm Ghostty itself can click raw OSC 8 (sanity check, bypasses claude)
printf '\n\e]8;;file:///tmp/test.html\e\\>>> CLICK ME <<<\e]8;;\e\\\n\n'
#    → cmd+click works ✅

# 2. Reproduce broken click (current default for many users)
CLAUDE_CODE_NO_FLICKER=1 FORCE_HYPERLINK=1 claude
#    → file paths render as underlined links, cmd+click does nothing ❌

# 3. Apply workaround
env -u CLAUDE_CODE_NO_FLICKER FORCE_HYPERLINK=1 claude
#    → cmd+click on file paths opens them ✅

Likely cause

The fullscreen render pipeline engaged by CLAUDE_CODE_NO_FLICKER=1 appears to strip OSC 8 sequences in a path that bypasses whatever guard FORCE_HYPERLINK=1 is meant to install. The v2.1.42 IDE-terminal fix may only cover the scrollback (non-fullscreen) path.

User-facing impact

Users who set CLAUDE_CODE_NO_FLICKER=1 for cleaner rendering currently must choose between (a) flickerless fullscreen + non-clickable file paths, or (b) flicker + clickable file paths. Worth documenting in terminal-config.md until upstream-fixed.

advenimus · 3 months ago

This needs proper fixing in an update for new TUI fullscreen mode

chad-fossa · 3 months ago

So I'm not sure why or how but I think this is fixed for me. I was just able to click a link in my custom status line... specifically my PR link now works

github-actions[bot] · 2 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

inwenis · 1 month ago

Partial workaround for statusline OSC being filtered: sequences that don't wrap visible text (OSC 9;9 cwd, OSC 0 title, progress) can be written to the console device directly from the statusline script — child processes inherit the session console, so CONOUT$ on Windows (or /dev/tty where the child has one) bypasses the filter. Write-up + scripts: https://github.com/ykdojo/claude-code-tips/issues/36

OSC 8 hyperlinks can't be fixed this way — they have to wrap the statusline text at its rendered position.