use crate::base::PixelSize;
use crate::render::present::{ColorDepth, PresentCaps};
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Capabilities {
pub truecolor: bool,
pub colors_256: bool,
pub kitty_keyboard: bool,
pub kitty_graphics: bool,
pub iterm2_images: bool,
pub sixel: bool,
pub sixel_max_registers: Option<u16>,
pub graphics_wrap: Option<WrapKind>,
pub cell_pixel_size: Option<PixelSize>,
pub sgr_pixel_mouse: bool,
pub sync_output_2026: bool,
pub sgr_mouse: bool,
pub bracketed_paste: bool,
pub focus_events: bool,
pub hyperlinks: bool,
pub undercurl: bool,
pub underline_color: bool,
pub deferred_wrap: bool,
pub osc52_copy: bool,
pub osc9_notify: bool,
pub osc99_notify: bool,
pub needs_tmux_passthrough: bool,
pub tmux_version: Option<String>,
pub unicode_ok: bool,
pub no_color: bool,
pub dumb: bool,
pub in_tmux: bool,
pub term_version: Option<String>,
}
impl Default for Capabilities {
fn default() -> Self {
Capabilities {
truecolor: false,
colors_256: false,
kitty_keyboard: false,
kitty_graphics: false,
iterm2_images: false,
sixel: false,
sixel_max_registers: None,
graphics_wrap: None,
cell_pixel_size: None,
sgr_pixel_mouse: false,
sync_output_2026: false,
sgr_mouse: false,
bracketed_paste: false,
focus_events: false,
hyperlinks: false,
undercurl: false,
underline_color: false,
deferred_wrap: true, osc52_copy: false,
osc9_notify: false,
osc99_notify: false,
needs_tmux_passthrough: false,
tmux_version: None,
unicode_ok: false,
no_color: false,
dumb: false,
in_tmux: false,
term_version: None,
}
}
}
impl Capabilities {
pub fn with(f: impl FnOnce(&mut Self)) -> Self {
let mut caps = Self::default();
f(&mut caps);
caps
}
pub fn detect_env() -> Self {
Self::detect_env_with(&|k| std::env::var(k).ok())
}
pub fn detect_env_with(lookup: &dyn Fn(&str) -> Option<String>) -> Self {
let get = |k: &str| lookup(k).unwrap_or_default();
let term = get("TERM").to_ascii_lowercase();
let term_program = get("TERM_PROGRAM");
let colorterm = get("COLORTERM").to_ascii_lowercase();
let mut c = Capabilities::default();
let dumb = term.is_empty() && term_program.is_empty() || term == "dumb";
let linux_console = term == "linux";
c.in_tmux =
!get("TMUX").is_empty() || term.starts_with("tmux") || term.starts_with("screen");
let kitty = !get("KITTY_WINDOW_ID").is_empty() || term == "xterm-kitty";
let wezterm = term_program == "WezTerm" || !get("WEZTERM_EXECUTABLE").is_empty();
let ghostty = term_program == "ghostty" || !get("GHOSTTY_RESOURCES_DIR").is_empty();
let iterm2 = term_program == "iTerm.app" || !get("ITERM_SESSION_ID").is_empty();
let windows_terminal = !get("WT_SESSION").is_empty();
let apple_terminal = term_program == "Apple_Terminal";
let vscode = term_program == "vscode";
let foot = term == "foot" || term.starts_with("foot-");
let vte_version: u32 = get("VTE_VERSION").parse().unwrap_or(0);
let modern = kitty || wezterm || ghostty || iterm2 || windows_terminal || foot || vscode;
c.truecolor =
colorterm == "truecolor" || colorterm == "24bit" || term.contains("direct") || modern;
c.colors_256 = c.truecolor || term.contains("256color") || apple_terminal;
c.kitty_keyboard = kitty || wezterm || ghostty || foot;
c.kitty_graphics = kitty || ghostty;
c.iterm2_images = iterm2 || wezterm;
c.sixel = foot || term.contains("sixel");
c.sync_output_2026 = modern;
c.hyperlinks = modern || vte_version >= 5000;
c.undercurl = kitty
|| wezterm
|| ghostty
|| foot
|| iterm2
|| windows_terminal
|| vte_version >= 5200;
c.underline_color = c.undercurl; c.osc52_copy = kitty || wezterm || ghostty || foot || iterm2 || windows_terminal;
c.osc9_notify = iterm2 || wezterm || ghostty;
c.osc99_notify = kitty;
let interactive = !dumb && !linux_console;
c.sgr_mouse = interactive;
c.bracketed_paste = interactive;
c.focus_events = interactive && !apple_terminal;
c.no_color = !get("NO_COLOR").is_empty();
if c.no_color {
c.truecolor = false;
c.colors_256 = false;
}
if c.in_tmux {
c.kitty_graphics = false;
c.iterm2_images = false;
c.sixel = false;
c.needs_tmux_passthrough = true;
if term_program == "tmux" {
let v = get("TERM_PROGRAM_VERSION");
if !v.is_empty() {
c.tmux_version = Some(v);
}
}
}
#[cfg(windows)]
{
c.unicode_ok = true; c.sgr_mouse = c.sgr_mouse && (modern || !term_program.is_empty());
}
#[cfg(not(windows))]
{
let locale = [get("LC_ALL"), get("LC_CTYPE"), get("LANG")]
.into_iter()
.find(|v| !v.is_empty())
.unwrap_or_default()
.to_ascii_lowercase();
c.unicode_ok = locale.contains("utf-8") || locale.contains("utf8");
}
if dumb {
let unicode_ok = c.unicode_ok;
let in_tmux = c.in_tmux;
let no_color = c.no_color;
let needs_tmux_passthrough = c.needs_tmux_passthrough;
let tmux_version = c.tmux_version.take();
c = Capabilities::default();
c.unicode_ok = unicode_ok;
c.in_tmux = in_tmux;
c.no_color = no_color;
c.needs_tmux_passthrough = needs_tmux_passthrough;
c.tmux_version = tmux_version;
c.dumb = true;
}
c
}
pub fn present_caps(&self) -> PresentCaps {
PresentCaps::from(self)
}
pub fn summary(&self) -> String {
fn yn(b: bool) -> &'static str {
if b {
"yes"
} else {
"no"
}
}
let mut s = String::with_capacity(768);
let color = if self.no_color {
"disabled (NO_COLOR)"
} else if self.truecolor {
"truecolor (24-bit)"
} else if self.colors_256 {
"256-color"
} else {
"16-color"
};
s.push_str("terminal capabilities\n");
if let Some(v) = &self.term_version {
s.push_str(&format!(" terminal : {v}\n"));
}
if self.dumb {
s.push_str(" TERM : dumb — escapes suppressed, probe skipped\n");
}
if self.in_tmux {
let ver = self.tmux_version.as_deref().unwrap_or("version unknown");
s.push_str(&format!(" multiplexer : tmux ({ver})\n"));
}
s.push_str(&format!(" color : {color}\n"));
s.push_str(&format!(
" unicode : {}\n",
if self.unicode_ok {
"UTF-8"
} else {
"uncertain (locale not UTF-8)"
}
));
s.push_str(&format!(
" kitty keyboard : {}\n",
yn(self.kitty_keyboard)
));
s.push_str(&format!(
" mouse : SGR {} / pixel units {}\n",
yn(self.sgr_mouse),
yn(self.sgr_pixel_mouse)
));
s.push_str(&format!(
" paste/focus : bracketed {} / focus events {}\n",
yn(self.bracketed_paste),
yn(self.focus_events)
));
s.push_str(&format!(
" sync output : {} (DEC 2026)\n",
yn(self.sync_output_2026)
));
let gfx = match (self.kitty_graphics, self.iterm2_images, self.sixel) {
(false, false, false) => "none (unicode mosaic fallback)".to_string(),
_ => {
let mut v = Vec::new();
if self.kitty_graphics {
v.push("kitty");
}
if self.iterm2_images {
v.push("iTerm2");
}
if self.sixel {
v.push("sixel");
}
let mut t = v.join(" + ");
if self.graphics_wrap == Some(WrapKind::Tmux) {
t.push_str(" (via tmux passthrough)");
}
t
}
};
s.push_str(&format!(" graphics : {gfx}\n"));
if let Some(r) = self.sixel_max_registers {
s.push_str(&format!(" sixel registers : {r}\n"));
}
if let Some(px) = self.cell_pixel_size {
s.push_str(&format!(" cell size : {}x{} px\n", px.w, px.h));
}
s.push_str(&format!(
" styling : undercurl {} / underline color {} / hyperlinks {}\n",
yn(self.undercurl),
yn(self.underline_color),
yn(self.hyperlinks)
));
let notify = match self.notify_channel() {
crate::term::verbs::NotifyChannel::Osc9 => "OSC 9",
crate::term::verbs::NotifyChannel::Osc99 => "OSC 99 (kitty)",
crate::term::verbs::NotifyChannel::BellOnly => "bell only",
};
s.push_str(&format!(
" desktop niceties: clipboard copy {} / notify {}\n",
yn(self.osc52_copy),
notify
));
s.push_str(&format!(
" deferred wrap : {} (presenter last-column strategy)\n",
yn(self.deferred_wrap)
));
s
}
pub fn summary_line(&self) -> String {
let mut t: Vec<String> = Vec::with_capacity(12);
if self.dumb {
t.push("dumb".into());
}
t.push(
if self.no_color {
"no-color"
} else if self.truecolor {
"truecolor"
} else if self.colors_256 {
"256color"
} else {
"16color"
}
.into(),
);
if self.kitty_keyboard {
t.push("kitty-kbd".into());
}
if self.kitty_graphics {
t.push("kitty-gfx".into());
}
if self.iterm2_images {
t.push("iterm2-img".into());
}
if self.sixel {
match self.sixel_max_registers {
Some(r) => t.push(format!("sixel({r})")),
None => t.push("sixel".into()),
}
}
if self.sync_output_2026 {
t.push("sync".into());
}
if self.sgr_mouse {
t.push(
if self.sgr_pixel_mouse {
"mouse-sgr(+pixels)"
} else {
"mouse-sgr"
}
.into(),
);
}
if self.bracketed_paste {
t.push("paste".into());
}
if self.focus_events {
t.push("focus".into());
}
if self.undercurl {
t.push("undercurl".into());
}
if self.osc52_copy {
t.push("osc52".into());
}
if self.in_tmux {
t.push(
if self.graphics_wrap == Some(WrapKind::Tmux) {
"tmux(passthrough)"
} else {
"tmux"
}
.into(),
);
}
t.join(", ")
}
pub fn notify_channel(&self) -> crate::term::verbs::NotifyChannel {
use crate::term::verbs::NotifyChannel;
if self.osc99_notify {
NotifyChannel::Osc99
} else if self.osc9_notify {
NotifyChannel::Osc9
} else {
NotifyChannel::BellOnly
}
}
pub fn graphics(&self) -> GraphicsCaps {
GraphicsCaps {
kitty_graphics: self.kitty_graphics,
iterm2_images: self.iterm2_images,
sixel: self.sixel,
sixel_max_registers: self.sixel_max_registers,
cell_pixel_size: self.cell_pixel_size,
wrap: self.graphics_wrap,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum WrapKind {
Tmux,
}
impl From<&Capabilities> for PresentCaps {
fn from(c: &Capabilities) -> PresentCaps {
let color = if c.truecolor {
ColorDepth::TrueColor
} else if c.colors_256 {
ColorDepth::Xterm256
} else {
ColorDepth::Ansi16
};
PresentCaps {
color,
sync_output_2026: c.sync_output_2026,
hyperlinks: c.hyperlinks,
undercurl: c.undercurl,
underline_color: c.underline_color,
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct GraphicsCaps {
pub kitty_graphics: bool,
pub iterm2_images: bool,
pub sixel: bool,
pub sixel_max_registers: Option<u16>,
pub cell_pixel_size: Option<PixelSize>,
pub wrap: Option<WrapKind>,
}
impl GraphicsCaps {
pub fn with(f: impl FnOnce(&mut Self)) -> Self {
let mut caps = Self::default();
f(&mut caps);
caps
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(missing_docs)] pub enum CapsReply {
KittyKeyboard { flags: u32 },
DecMode { mode: u32, status: u8 },
PrimaryDa { params: Vec<u32> },
XtVersion { text: String },
KittyGraphics { raw: Vec<u8> },
XtGetTcap { raw: Vec<u8> },
CursorPos { row: u32, col: u32 },
XtSmGraphics { item: u32, status: u32, value: u32 },
WindowOp { op: u32, a: u32, b: u32 },
Osc { raw: Vec<u8> },
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> Capabilities {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Capabilities::detect_env_with(&move |k| map.get(k).cloned())
}
#[test]
fn kitty_env_detected() {
let c = env(&[
("TERM", "xterm-kitty"),
("KITTY_WINDOW_ID", "1"),
("LANG", "en_US.UTF-8"),
]);
assert!(c.truecolor && c.colors_256);
assert!(c.kitty_keyboard && c.kitty_graphics && c.undercurl);
assert!(c.sync_output_2026 && c.hyperlinks && c.unicode_ok);
assert!(!c.iterm2_images && !c.in_tmux && !c.dumb);
}
#[test]
fn iterm_and_wezterm_prefer_iterm_images() {
let it = env(&[("TERM_PROGRAM", "iTerm.app"), ("TERM", "xterm-256color")]);
assert!(it.iterm2_images && !it.kitty_graphics);
let wt = env(&[("TERM_PROGRAM", "WezTerm"), ("TERM", "xterm-256color")]);
assert!(wt.iterm2_images && !wt.kitty_graphics && wt.kitty_keyboard);
}
#[test]
fn tmux_masks_graphics_and_records_passthrough_need() {
let c = env(&[
("TMUX", "/tmp/tmux-1000/default,123,0"),
("TERM", "tmux-256color"),
("KITTY_WINDOW_ID", "1"), ]);
assert!(c.in_tmux && c.needs_tmux_passthrough);
assert!(!c.kitty_graphics && !c.iterm2_images && !c.sixel);
assert!(c.colors_256);
assert_eq!(c.tmux_version, None); assert!(c.osc52_copy);
let c = env(&[
("TMUX", "/tmp/tmux-1000/default,123,0"),
("TERM", "tmux-256color"),
("TERM_PROGRAM", "tmux"),
("TERM_PROGRAM_VERSION", "3.4"),
]);
assert_eq!(c.tmux_version.as_deref(), Some("3.4"));
assert!(c.needs_tmux_passthrough);
}
#[test]
fn clipboard_and_notify_gates() {
use crate::term::verbs::NotifyChannel;
let c = env(&[("TERM", "xterm-kitty"), ("KITTY_WINDOW_ID", "1")]);
assert!(c.osc52_copy);
assert!(!c.osc9_notify, "kitty speaks OSC 99, not OSC 9");
assert!(c.osc99_notify);
assert_eq!(c.notify_channel(), NotifyChannel::Osc99);
let c = env(&[("TERM_PROGRAM", "WezTerm"), ("TERM", "xterm-256color")]);
assert!(c.osc52_copy && c.osc9_notify);
assert_eq!(c.notify_channel(), NotifyChannel::Osc9);
let c = env(&[("TERM_PROGRAM", "ghostty"), ("TERM", "xterm-ghostty")]);
assert_eq!(c.notify_channel(), NotifyChannel::Osc9);
let c = env(&[("TERM", "xterm-256color")]);
assert!(!c.osc52_copy && !c.osc9_notify && !c.osc99_notify);
assert_eq!(c.notify_channel(), NotifyChannel::BellOnly);
}
#[test]
fn dumb_terminal_gets_nothing_and_is_flagged() {
let c = env(&[("TERM", "dumb"), ("LANG", "en_US.UTF-8")]);
assert!(c.dumb);
assert_eq!(
c,
Capabilities {
unicode_ok: true,
dumb: true,
..Capabilities::default()
}
);
assert!(env(&[]).dumb);
assert!(!env(&[("TERM", "xterm-256color")]).dumb);
}
#[test]
fn linux_console_keeps_color_drops_mouse() {
let c = env(&[("TERM", "linux")]);
assert!(!c.sgr_mouse && !c.bracketed_paste && !c.focus_events);
assert!(!c.truecolor && !c.dumb);
}
#[test]
fn plain_xterm_256color() {
let c = env(&[("TERM", "xterm-256color"), ("COLORTERM", "truecolor")]);
assert!(c.truecolor && c.colors_256 && c.bracketed_paste);
#[cfg(not(windows))]
assert!(c.sgr_mouse);
#[cfg(windows)]
assert!(!c.sgr_mouse, "bare env must not claim mouse on windows");
assert!(!c.kitty_keyboard && !c.kitty_graphics && !c.sixel);
assert!(!c.undercurl, "no undercurl evidence for plain xterm");
}
#[test]
fn no_color_forces_depth_down_not_features() {
let c = env(&[
("TERM", "xterm-kitty"),
("COLORTERM", "truecolor"),
("NO_COLOR", "1"),
]);
assert!(c.no_color);
assert!(!c.truecolor && !c.colors_256);
assert!(c.sgr_mouse && c.bracketed_paste && c.kitty_keyboard);
assert_eq!(c.present_caps().color, ColorDepth::Ansi16);
}
#[test]
fn deferred_wrap_defaults_true_everywhere() {
assert!(Capabilities::default().deferred_wrap);
assert!(env(&[("TERM", "xterm-256color")]).deferred_wrap);
assert!(env(&[("TERM", "dumb")]).deferred_wrap);
}
#[test]
fn present_caps_conversion() {
let c = env(&[("TERM", "xterm-kitty"), ("KITTY_WINDOW_ID", "1")]);
let p = c.present_caps();
assert_eq!(p.color, ColorDepth::TrueColor);
assert!(p.sync_output_2026 && p.hyperlinks && p.undercurl);
assert!(p.underline_color);
let c = env(&[("TERM", "xterm-256color")]);
assert_eq!(c.present_caps().color, ColorDepth::Xterm256);
let c = env(&[("TERM", "vt100")]);
assert_eq!(c.present_caps().color, ColorDepth::Ansi16);
assert_eq!(PresentCaps::from(&c), c.present_caps());
}
#[test]
fn summary_reads_true_and_stays_honest() {
let mut c = env(&[
("TERM", "xterm-kitty"),
("KITTY_WINDOW_ID", "1"),
("LANG", "en_US.UTF-8"),
]);
c.cell_pixel_size = Some(PixelSize::new(9, 18));
c.sixel_max_registers = Some(256);
c.term_version = Some("kitty 0.38.1".into());
let s = c.summary();
assert!(s.contains("terminal : kitty 0.38.1"), "{s}");
assert!(s.contains("color : truecolor"), "{s}");
assert!(s.contains("kitty keyboard : yes"), "{s}");
assert!(s.contains("graphics : kitty"), "{s}");
assert!(s.contains("cell size : 9x18 px"), "{s}");
assert!(s.contains("notify OSC 99"), "{s}");
assert!(s.lines().count() >= 10, "multi-line report: {s}");
let c = env(&[("TERM", "dumb"), ("NO_COLOR", "1")]);
let s = c.summary();
assert!(s.contains("dumb — escapes suppressed"), "{s}");
assert!(s.contains("disabled (NO_COLOR)"), "{s}");
assert!(
s.contains("graphics : none (unicode mosaic fallback)"),
"{s}"
);
assert!(s.contains("notify bell only"), "{s}");
let mut c = env(&[("TMUX", "/tmp/t,1,0"), ("TERM", "tmux-256color")]);
c.kitty_graphics = true;
c.graphics_wrap = Some(WrapKind::Tmux);
c.tmux_version = Some("tmux 3.7b".into());
let s = c.summary();
assert!(s.contains("multiplexer : tmux (tmux 3.7b)"), "{s}");
assert!(s.contains("kitty (via tmux passthrough)"), "{s}");
}
#[test]
fn summary_line_tokens_track_truth() {
let mut c = env(&[("TERM", "xterm-kitty"), ("KITTY_WINDOW_ID", "1")]);
c.sixel = true;
c.sixel_max_registers = Some(256);
c.sgr_pixel_mouse = true;
let line = c.summary_line();
assert_eq!(
line,
"truecolor, kitty-kbd, kitty-gfx, sixel(256), sync, \
mouse-sgr(+pixels), paste, focus, undercurl, osc52"
);
let mut c = env(&[("TMUX", "/tmp/t,1,0"), ("TERM", "tmux-256color")]);
assert!(c.summary_line().ends_with(", tmux"), "{}", c.summary_line());
c.graphics_wrap = Some(WrapKind::Tmux);
assert!(
c.summary_line().ends_with(", tmux(passthrough)"),
"{}",
c.summary_line()
);
let c = env(&[("TERM", "dumb"), ("NO_COLOR", "1")]);
assert_eq!(c.summary_line(), "dumb, no-color");
}
#[test]
fn graphics_view_mirrors_fields() {
let mut c = env(&[("TERM", "xterm-kitty"), ("KITTY_WINDOW_ID", "1")]);
c.sixel_max_registers = Some(256);
c.cell_pixel_size = Some(PixelSize::new(9, 18));
let g = c.graphics();
assert!(g.kitty_graphics && !g.iterm2_images && !g.sixel);
assert_eq!(g.sixel_max_registers, Some(256));
assert_eq!(g.cell_pixel_size, Some(PixelSize::new(9, 18)));
}
}