Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
64dc6b91b0 fix(pet): detect terminals more broadly
incl. windows terminal and ghostty
2026-06-26 14:57:23 -04:00
2 changed files with 75 additions and 44 deletions

View File

@@ -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.

74
agent/pet/terminal.py Normal file
View File

@@ -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"