From 64dc6b91b0ddd0cb80051b586597545fe2c79dd5 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 26 Jun 2026 14:57:23 -0400 Subject: [PATCH] fix(pet): detect terminals more broadly incl. windows terminal and ghostty --- agent/pet/render.py | 45 +------------------------- agent/pet/terminal.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 44 deletions(-) create mode 100644 agent/pet/terminal.py diff --git a/agent/pet/render.py b/agent/pet/render.py index 1618c0751d2..b4b9a3cd0a5 100644 --- a/agent/pet/render.py +++ b/agent/pet/render.py @@ -17,6 +17,7 @@ empty string rather than raising. """ from __future__ import annotations +from agent.pet.terminal import detect_terminal_graphics import base64 import io @@ -45,50 +46,6 @@ RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") # Terminal capability detection # ───────────────────────────────────────────────────────────────────────── -def detect_terminal_graphics() -> str: - """Best-effort detection of the richest graphics protocol available. - - Env-based (non-blocking — we never issue a DA1/terminal query that could - hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` / - ``unicode``. Conservative: unknown terminals get ``unicode``, which works - anywhere with truecolor. - """ - term = os.environ.get("TERM", "").lower() - term_program = os.environ.get("TERM_PROGRAM", "").lower() - - # The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode - # authoritatively but does NOT scrub the terminal env vars it inherits when - # launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …). - # Trusting those leaks emits an image protocol the embedded xterm.js can't - # display — you get a blank frame. Inline images there are opt-in - # (terminal.integrated.enableImages), so default to half-blocks, which - # always render in its truecolor grid. Users who enabled images can pin - # display.pet.render_mode explicitly. - if term_program == "vscode": - return "unicode" - - # kitty graphics protocol - if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term: - return "kitty" - if term_program in {"ghostty"}: - return "kitty" - - # WezTerm speaks both kitty and iterm; prefer kitty (richer placement). - if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"): - return "kitty" - - # iTerm2 inline images - if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): - return "iterm" - - # sixel-capable terminals (env heuristics only) - if term_program in {"mintty"} or "foot" in term or "mlterm" in term: - return "sixel" - if "sixel" in term: - return "sixel" - - return "unicode" - def resolve_mode(configured: str | None, *, stream=None) -> str: """Resolve the effective render mode from config + the environment. diff --git a/agent/pet/terminal.py b/agent/pet/terminal.py new file mode 100644 index 00000000000..71cea4ea844 --- /dev/null +++ b/agent/pet/terminal.py @@ -0,0 +1,74 @@ +from __future__ import annotations +import os +from dataclasses import dataclass, field + + +RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") + +@dataclass(frozen=True) +class TerminalProfile: + """Declarative description of a terminal's detection fingerprint.""" + mode: str # one of RENDER_MODES + # Any of these env vars being set (non-empty) is a match + env_vars: tuple[str, ...] = () + # $TERM_PROGRAM must equal one of these (case-insensitive) + term_program_vals: tuple[str, ...] = () + # Any of these strings appearing in $TERM is a match + term_substrings: tuple[str, ...] = () + + def matches(self) -> bool: + if any(os.environ.get(v) for v in self.env_vars): + return True + tp = os.environ.get("TERM_PROGRAM", "").lower() + if tp and tp in self.term_program_vals: + return True + term = os.environ.get("TERM", "").lower() + if any(s in term for s in self.term_substrings): + return True + return False + + +# ── Registry (priority order — first match wins) ─────────────────────────── +# +# VS Code / Cursor override comes first: their embedded xterm.js leaks +# KITTY_WINDOW_ID / ITERM_SESSION_ID from the parent shell, so we must +# short-circuit before any of the real-graphics entries. +# +_TERMINAL_PROFILES: tuple[TerminalProfile, ...] = ( + TerminalProfile( + mode="unicode", + term_program_vals=("vscode",), + ), + + TerminalProfile( + mode="kitty", + env_vars=("KITTY_WINDOW_ID","GHOSTTY_RESOURCES_DIR","WEZTERM_PANE",), + term_program_vals=("kitty", "ghostty",), + term_substrings=("kitty","ghostty","wezterm", ), + ), + TerminalProfile( + mode="iterm", + env_vars=("ITERM_SESSION_ID",), + term_program_vals=("iterm.app",), + ), + TerminalProfile( + mode="sixel", + env_vars=("WT_SESSION",), + term_substrings=("mlterm","contour","sixel",), + term_program_vals=("mintty","foot","contour",), + ), +) + + +def detect_terminal_graphics() -> str: + """Return the richest graphics mode available in the current terminal. + + Non-blocking — no DA1/terminal query is issued, as those can break pipes + in strange ways. + Walks ``_TERMINAL_PROFILES` in priority order; returns the first match's mode, or ``"unicode"`` as the + safe universal fallback. + """ + for profile in _TERMINAL_PROFILES: + if profile.matches(): + return profile.mode + return "unicode" \ No newline at end of file