Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1166 lines
45 KiB
Rust
1166 lines
45 KiB
Rust
//! Terminal detection utilities.
|
|
//!
|
|
//! Detects terminal emulator, multiplexer, and Byobu from environment variables.
|
|
//! Pure env-map helpers (`detect_*_from_env`) enable full matrix testing.
|
|
|
|
use std::collections::HashMap;
|
|
use std::process::Command;
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use crate::host::HostOs;
|
|
|
|
pub mod embedded_editor;
|
|
pub mod hyperlinks;
|
|
pub mod image;
|
|
pub mod keyboard;
|
|
pub mod overlay;
|
|
pub(crate) mod probe;
|
|
pub mod xtversion;
|
|
|
|
pub use embedded_editor::{EmbeddedEditor, embedded_editor_from_env};
|
|
pub use hyperlinks::{
|
|
HyperlinkCapabilities, Osc8Support, SchemeFilter, SetDefaultCursor, SetPointerCursor,
|
|
hyperlink_capabilities,
|
|
};
|
|
pub use keyboard::{KeyboardCapabilities, ModifierDelivery, ModifierFate, keyboard_capabilities};
|
|
|
|
#[cfg(test)]
|
|
mod test;
|
|
|
|
/// Test-only: build an env `HashMap` from pairs. Shared by the `test` submodule
|
|
/// and `embedded_editor`'s tests so the helper isn't duplicated.
|
|
#[cfg(test)]
|
|
pub(crate) fn env_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
|
pairs
|
|
.iter()
|
|
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
// TODO: make term seq codes invariant in a crate.
|
|
/// Tracks whether Kitty keyboard enhancement flags were pushed during
|
|
/// `init_terminal`, so teardown paths (`restore_terminal`, panic hook)
|
|
/// only pop when flags were actually pushed.
|
|
static KITTY_FLAGS_PUSHED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Whether Kitty keyboard enhancement flags were actually pushed during
|
|
/// `init_terminal` — i.e. the brand wasn't in the skip list *and* the
|
|
/// runtime probe (`supports_keyboard_enhancement`) succeeded. False means
|
|
/// modified keys (Shift+Enter, Ctrl+.) arrive as legacy bytes.
|
|
pub fn kitty_flags_pushed() -> bool {
|
|
KITTY_FLAGS_PUSHED.load(Ordering::Acquire)
|
|
}
|
|
|
|
/// Record whether Kitty keyboard enhancement flags were pushed during
|
|
/// `init_terminal`.
|
|
pub fn set_kitty_flags_pushed(v: bool) {
|
|
KITTY_FLAGS_PUSHED.store(v, Ordering::Release)
|
|
}
|
|
|
|
/// Atomically clear the Kitty-flags-pushed state, returning the prior value.
|
|
/// Used by teardown paths so concurrent callers cannot both pop.
|
|
pub fn take_kitty_flags_pushed() -> bool {
|
|
KITTY_FLAGS_PUSHED.swap(false, Ordering::AcqRel)
|
|
}
|
|
|
|
/// Run `tmux show-option -gqv <option>` and return the trimmed value,
|
|
/// or `None` if the subprocess fails or returns empty / whitespace.
|
|
/// Stderr is suppressed so startup probes don't leak shell noise.
|
|
pub fn tmux_show_option(option: &str) -> Option<String> {
|
|
let mut cmd = Command::new("tmux");
|
|
cmd.args(["show-option", "-gqv", option])
|
|
.stdin(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
|
let output = cmd.output().ok()?;
|
|
parse_tmux_show_option_output(output.status.success(), &output.stdout)
|
|
}
|
|
|
|
/// Pure post-processing for `tmux show-option`: collapses subprocess
|
|
/// failure, empty stdout, and whitespace-only stdout to `None`.
|
|
pub fn parse_tmux_show_option_output(status_success: bool, stdout: &[u8]) -> Option<String> {
|
|
if !status_success {
|
|
return None;
|
|
}
|
|
let val = String::from_utf8_lossy(stdout).trim().to_string();
|
|
if val.is_empty() { None } else { Some(val) }
|
|
}
|
|
|
|
/// Known terminal emulator categories.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, strum::Display)]
|
|
pub enum TerminalName {
|
|
/// Apple Terminal (Terminal.app).
|
|
#[strum(to_string = "Apple Terminal")]
|
|
AppleTerminal,
|
|
/// Ghostty terminal emulator.
|
|
Ghostty,
|
|
/// iTerm2 terminal emulator.
|
|
#[strum(to_string = "iTerm2")]
|
|
Iterm2,
|
|
/// Warp terminal emulator.
|
|
#[strum(to_string = "Warp")]
|
|
WarpTerminal,
|
|
/// VS Code integrated terminal.
|
|
#[strum(to_string = "VS Code")]
|
|
VsCode,
|
|
/// Integrated terminal for the `Cursor` brand (VS Code family).
|
|
Cursor,
|
|
/// Integrated terminal for the `Windsurf` brand (VS Code family).
|
|
Windsurf,
|
|
/// Zed editor integrated terminal.
|
|
Zed,
|
|
/// WezTerm terminal emulator.
|
|
WezTerm,
|
|
/// kitty terminal emulator.
|
|
#[strum(to_string = "Kitty")]
|
|
Kitty,
|
|
/// Alacritty terminal emulator.
|
|
Alacritty,
|
|
/// Rio terminal emulator.
|
|
Rio,
|
|
/// foot terminal emulator (Wayland-native, Linux-only). Full native
|
|
/// Kitty keyboard protocol support. Detected via TERM.
|
|
#[strum(to_string = "foot")]
|
|
Foot,
|
|
/// JetBrains IDE integrated terminal (JediTerm — IntelliJ, PhpStorm, etc.).
|
|
/// No runtime capability probing is possible (no TERM_FEATURES, no
|
|
/// XTVERSION, DA1 is bare VT102). Classic vs Reworked 2025 engine is
|
|
/// indistinguishable. All capabilities are conservative/Unknown.
|
|
#[strum(to_string = "JetBrains")]
|
|
JetBrains,
|
|
/// Kigi Desktop (Electron app).
|
|
#[strum(to_string = "Kigi Desktop")]
|
|
KigiDesktop,
|
|
/// VTE-based terminal (GNOME Terminal, kgx/GNOME Console, Tilix, etc.).
|
|
#[strum(to_string = "VTE")]
|
|
Vte,
|
|
/// Terminator terminal emulator (Python/GTK, VTE-based). Detected via the
|
|
/// `TERMINATOR_UUID` env var it exports on every child process, or
|
|
/// `TERM_PROGRAM=terminator`.
|
|
Terminator,
|
|
/// Windows Terminal (wt, the default terminal on Windows 11+).
|
|
#[strum(to_string = "Windows Terminal")]
|
|
WindowsTerminal,
|
|
/// Otty (otty.sh). Wraps macOS IME commits in bracketed paste.
|
|
#[strum(to_string = "Otty")]
|
|
Otty,
|
|
/// Unknown terminal.
|
|
#[default]
|
|
Unknown,
|
|
}
|
|
|
|
impl TerminalName {
|
|
pub fn is_vte_based(self) -> bool {
|
|
// WHY: single source of truth for the VTE family
|
|
matches!(self, Self::Vte | Self::Terminator)
|
|
}
|
|
|
|
/// VS Code integrated terminal and xterm.js-based IDE embeds (including forks).
|
|
pub fn is_vscode_family(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::VsCode | Self::Cursor | Self::Windsurf | Self::Zed
|
|
)
|
|
}
|
|
|
|
/// Brands whose capabilities are not positively classified — share
|
|
/// [`Self::Unknown`]'s fail-closed posture (no KKP probe, conservative
|
|
/// hyperlinks/notifications/focus, etc.).
|
|
pub fn is_capability_unclassified(self) -> bool {
|
|
matches!(self, Self::Unknown | Self::Otty)
|
|
}
|
|
|
|
/// Host applies OSC 52 writes to the system pasteboard (fail closed).
|
|
pub fn supports_osc52_clipboard(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Ghostty
|
|
| Self::Kitty
|
|
| Self::WezTerm
|
|
| Self::Alacritty
|
|
| Self::Foot
|
|
| Self::Rio
|
|
| Self::WindowsTerminal
|
|
| Self::Iterm2
|
|
| Self::VsCode
|
|
| Self::Cursor
|
|
| Self::Windsurf
|
|
| Self::Zed
|
|
)
|
|
}
|
|
|
|
/// Only Otty is known to wrap macOS IME commits in bracketed paste.
|
|
pub fn delivers_ime_as_bracketed_paste(self) -> bool {
|
|
matches!(self, Self::Otty)
|
|
}
|
|
}
|
|
|
|
impl TerminalContext {
|
|
pub fn is_vte_based(&self) -> bool {
|
|
// WHY: covers brand + legacy version marker
|
|
self.brand.is_vte_based() || self.vte_version.is_some()
|
|
}
|
|
}
|
|
|
|
/// The kind of terminal multiplexer wrapping the session.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, strum::Display)]
|
|
pub enum MultiplexerKind {
|
|
/// tmux (including Byobu-on-tmux).
|
|
#[strum(to_string = "tmux")]
|
|
Tmux,
|
|
/// GNU screen (including Byobu-on-screen).
|
|
#[strum(to_string = "GNU screen")]
|
|
Screen,
|
|
Zellij,
|
|
/// cmux (Ghostty-backed macOS terminal multiplexer).
|
|
#[strum(to_string = "cmux")]
|
|
Cmux,
|
|
/// No recognized multiplexer detected (does not rule out unknown ones).
|
|
#[default]
|
|
#[strum(to_string = "None detected")]
|
|
Undetected,
|
|
}
|
|
|
|
impl MultiplexerKind {
|
|
/// Whether this multiplexer intercepts CSI queries (e.g. XTVERSION)
|
|
/// instead of passing them through to the outer terminal.
|
|
pub fn intercepts_csi_queries(self) -> bool {
|
|
matches!(self, Self::Tmux | Self::Screen | Self::Zellij)
|
|
}
|
|
}
|
|
|
|
/// The Byobu backend, when Byobu markers are present.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, strum::Display)]
|
|
pub enum ByobuBackend {
|
|
/// Unknown backend (default; never produced by detection).
|
|
#[default]
|
|
Unknown,
|
|
/// Byobu wrapping tmux.
|
|
#[strum(to_string = "tmux")]
|
|
Tmux,
|
|
/// Byobu wrapping GNU screen.
|
|
#[strum(to_string = "GNU screen")]
|
|
Screen,
|
|
}
|
|
|
|
/// Cached tmux client metadata needed for downstream policy decisions.
|
|
///
|
|
/// Fields are gathered at startup from environment variables; no live
|
|
/// subprocess calls are made here. Live tmux-option queries remain in
|
|
/// [`crate::diagnostics`].
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct TmuxClientMeta {
|
|
/// The raw `TMUX` variable value (e.g. `/tmp/tmux-501/default,12345,0`).
|
|
pub tmux_env: Option<String>,
|
|
/// The `TMUX_PANE` value (e.g. `%0`).
|
|
pub tmux_pane: Option<String>,
|
|
}
|
|
|
|
/// Full terminal context serving as the single source of
|
|
/// truth that later features (warnings, fullscreen policy,
|
|
/// clipboard routing) should consume.
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
pub struct TerminalContext {
|
|
/// The effective terminal emulator brand for capability and display
|
|
/// decisions. On native Windows an `Unknown` env detection is resolved
|
|
/// to [`TerminalName::WindowsTerminal`] (see
|
|
/// `refine_unknown_brand_for_host`).
|
|
pub brand: TerminalName,
|
|
/// The raw brand from environment detection, before the native-Windows
|
|
/// `Unknown -> WindowsTerminal` fallback applied to `brand`. Consult this
|
|
/// (not `brand`) for conservative, default-deny decisions that must not
|
|
/// trust the assumed brand — e.g. the legacy-Windows glyph fallback and
|
|
/// the unidentified-terminal `Shift+Enter` gate.
|
|
pub env_brand: TerminalName,
|
|
/// The detected multiplexer wrapping the session.
|
|
pub multiplexer: MultiplexerKind,
|
|
/// Whether Byobu is wrapping the session, and which backend it uses.
|
|
pub byobu: Option<ByobuBackend>,
|
|
/// Which embedded editor `:terminal` kigi is running inside, if any.
|
|
pub embedded_editor: Option<EmbeddedEditor>,
|
|
/// tmux client metadata (populated only when `multiplexer == Tmux`).
|
|
pub tmux_meta: TmuxClientMeta,
|
|
/// Whether the session is inside a remote SSH connection.
|
|
pub is_ssh: bool,
|
|
/// The raw `TERM` environment variable (e.g. `xterm-256color`, `screen`).
|
|
pub term_var: Option<String>,
|
|
/// The tmux server version (e.g. `"tmux 3.4"`), populated only when
|
|
/// `multiplexer == Tmux`. Detected via `tmux -V` subprocess at startup.
|
|
pub tmux_version: Option<String>,
|
|
/// The VTE version (e.g. `"7402"` for VTE 0.74.2).
|
|
/// `None` when not running inside a VTE terminal.
|
|
pub vte_version: Option<String>,
|
|
/// Value of tmux's `extended-keys` global option (`"on"`, `"off"`,
|
|
/// `"always"`); populated only when `multiplexer == Tmux`.
|
|
pub tmux_extended_keys: Option<String>,
|
|
/// The `TERM_PROGRAM_VERSION` environment variable (e.g. `"3.5.6"` for
|
|
/// iTerm2, `"1.1.3"` for Ghostty). Used for version-gating features
|
|
/// that require a minimum terminal version.
|
|
pub term_program_version: Option<String>,
|
|
}
|
|
|
|
impl TerminalContext {
|
|
/// Returns `true` if the session is inside any tmux-backed environment
|
|
/// (plain tmux or Byobu-on-tmux).
|
|
pub fn is_tmux_backed(&self) -> bool {
|
|
self.multiplexer == MultiplexerKind::Tmux
|
|
}
|
|
|
|
/// Per-terminal keyboard capabilities for this brand on the current host.
|
|
pub fn keyboard_capabilities(&self) -> KeyboardCapabilities {
|
|
keyboard_capabilities(self.brand)
|
|
}
|
|
|
|
/// Per-terminal hyperlink (OSC 8) capabilities for this brand.
|
|
pub fn hyperlink_capabilities(&self) -> HyperlinkCapabilities {
|
|
hyperlink_capabilities(self.brand)
|
|
}
|
|
|
|
/// Returns `true` if the session is inside Byobu regardless of backend.
|
|
pub fn is_byobu(&self) -> bool {
|
|
self.byobu.is_some()
|
|
}
|
|
|
|
/// Whether an outer layer (embedded-editor :terminal or multiplexer) can
|
|
/// repaint our pane out of band, stranding rows until a full clear. A heal
|
|
/// keyed off this only fires when a FocusGained actually reaches kigi, which
|
|
/// needs focus reporting enabled upstream (e.g. tmux `focus-events on`, off
|
|
/// by default).
|
|
pub fn repaints_pane_out_of_band(&self) -> bool {
|
|
self.embedded_editor.is_some() || self.multiplexer != MultiplexerKind::Undetected
|
|
}
|
|
|
|
/// Returns the tmux config path appropriate for the environment.
|
|
///
|
|
/// In Byobu-on-tmux, this is `~/.byobu/.tmux.conf`; otherwise
|
|
/// `~/.tmux.conf`.
|
|
pub fn tmux_config_path(&self) -> String {
|
|
if self.byobu == Some(ByobuBackend::Tmux) {
|
|
"~/.byobu/.tmux.conf".to_owned()
|
|
} else {
|
|
"~/.tmux.conf".to_owned()
|
|
}
|
|
}
|
|
|
|
/// Returns the `TERM` variable value, or `"n/a"` if unset.
|
|
pub fn term_var_or_na(&self) -> &str {
|
|
self.term_var.as_deref().unwrap_or("n/a")
|
|
}
|
|
|
|
/// Returns the tmux version string, or `"n/a"` if not in tmux or
|
|
/// detection failed.
|
|
pub fn tmux_version_or_na(&self) -> &str {
|
|
self.tmux_version.as_deref().unwrap_or("n/a")
|
|
}
|
|
|
|
/// Returns the reason to skip Kitty keyboard flags, or `None` if the
|
|
/// environment is compatible.
|
|
///
|
|
/// Terminal-emulator reasons (vscode, apple_terminal, vte, windows_terminal) take
|
|
/// precedence over multiplexer reasons so the user is pointed at the
|
|
/// deeper cause.
|
|
pub fn kitty_skip_reason(&self) -> Option<&'static str> {
|
|
let is_tmux_3_3_later = self.is_tmux_version_or_later(3, 3);
|
|
if matches!(
|
|
self.brand,
|
|
TerminalName::VsCode
|
|
| TerminalName::Cursor
|
|
| TerminalName::Windsurf
|
|
| TerminalName::Zed
|
|
) {
|
|
return Some("vscode");
|
|
}
|
|
if self.brand == TerminalName::AppleTerminal {
|
|
return Some("apple_terminal");
|
|
}
|
|
if self.is_vte_based() {
|
|
// WHY: central helper replaces VTE duplication
|
|
return Some("vte");
|
|
}
|
|
if self.brand == TerminalName::WindowsTerminal {
|
|
return Some("windows_terminal");
|
|
}
|
|
if self.brand == TerminalName::JetBrains {
|
|
return Some("jetbrains");
|
|
}
|
|
if self.multiplexer == MultiplexerKind::Screen {
|
|
return Some("screen");
|
|
}
|
|
if self.multiplexer == MultiplexerKind::Tmux && !is_tmux_3_3_later {
|
|
return Some("tmux_old");
|
|
}
|
|
if self.multiplexer == MultiplexerKind::Tmux
|
|
&& is_tmux_3_3_later
|
|
&& self.tmux_extended_keys.as_deref() == Some("off")
|
|
{
|
|
return Some("tmux_extended_keys_off");
|
|
}
|
|
// No positive evidence of KKP support — skip to avoid xterm.js
|
|
// mis-encoding shifted keys (https://github.com/xtermjs/xterm.js/issues/5823).
|
|
// Probing an unresponsive terminal blocks startup.
|
|
if self.brand.is_capability_unclassified()
|
|
&& self.multiplexer == MultiplexerKind::Undetected
|
|
{
|
|
return Some("unknown_no_multiplexer");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Returns the reason inline images / terminal graphics protocols are
|
|
/// disabled, or `None` if the environment is compatible.
|
|
pub fn graphics_protocol_skip_reason(&self) -> Option<&'static str> {
|
|
if self.is_tmux_backed() {
|
|
return Some("tmux");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether this terminal leaks mouse-tracking reports into the input as raw
|
|
/// text instead of consuming them, corrupting the prompt.
|
|
///
|
|
/// JediTerm (JetBrains IDEs) on Windows is the known offender: crossterm's
|
|
/// Windows input source only decodes native console mouse records, not the
|
|
/// VT `\e[M…` byte stream JediTerm emits, so those bytes surface as key
|
|
/// presses. macOS/Linux crossterm parses them, so the leak is Windows-only.
|
|
/// The pager defaults these sessions to minimal mode (no mouse capture).
|
|
pub fn mouse_reporting_leaks_as_raw_text(&self) -> bool {
|
|
mouse_reporting_leaks(self.brand, HostOs::current())
|
|
}
|
|
|
|
/// Whether the running terminal cannot distinguish `Shift+Enter` from
|
|
/// bare `Enter` at the byte level, so the UI should advertise
|
|
/// `Alt+Enter` for newline insertion instead.
|
|
///
|
|
/// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol
|
|
/// (KKP) to be negotiated. This returns `true` for the environments
|
|
/// where the pager cannot rely on KKP for a usable `Shift+Enter`:
|
|
///
|
|
/// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose
|
|
/// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with
|
|
/// KKP, merged in
|
|
/// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)).
|
|
/// Also true when the brand is detected as VTE but `VTE_VERSION` is
|
|
/// missing or unparseable — we conservatively assume old.
|
|
/// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family /
|
|
/// xterm.js IDE forks**. xterm.js only partially implements KKP —
|
|
/// it mis-encodes shifted printable keys — so the pager deliberately
|
|
/// never negotiates KKP for them (see [`Self::kitty_skip_reason`]
|
|
/// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)).
|
|
/// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`,
|
|
/// byte-for-byte identical to `Enter`.
|
|
/// 3. **Unidentified terminals with no multiplexer**, where the pager
|
|
/// also skips KKP (no positive evidence of support — typically
|
|
/// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't
|
|
/// forwarded and the brand falls back to `Unknown`).
|
|
///
|
|
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
|
|
/// newline chord and is what the UI advertises.
|
|
pub fn shift_enter_unavailable(&self) -> bool {
|
|
// WHY: central helper + version gating
|
|
let is_vte = self.is_vte_based();
|
|
if is_vte {
|
|
return match self
|
|
.vte_version
|
|
.as_deref()
|
|
.and_then(|v| v.parse::<u32>().ok())
|
|
{
|
|
Some(ver) => ver < 8200,
|
|
// Brand=Vte but no parseable version — conservative: assume old.
|
|
None => true,
|
|
};
|
|
}
|
|
|
|
// VS Code / xterm.js and its forks: KKP is never negotiated, so
|
|
// Shift+Enter arrives as a bare CR indistinguishable from Enter.
|
|
if matches!(
|
|
self.brand,
|
|
TerminalName::VsCode
|
|
| TerminalName::Cursor
|
|
| TerminalName::Windsurf
|
|
| TerminalName::Zed
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Unidentified / unclassified brand with no multiplexer: KKP is
|
|
// skipped (see `kitty_skip_reason`). This is the common
|
|
// VS Code-over-SSH shape (brand falls back to Unknown). On native
|
|
// Windows the effective `brand` is refined to WindowsTerminal, so
|
|
// consult `env_brand` — a bare ConHost is still env-Unknown and
|
|
// must advertise Alt+Enter even though we optimistically treat it
|
|
// as WT for capabilities.
|
|
if self.env_brand.is_capability_unclassified()
|
|
&& self.multiplexer == MultiplexerKind::Undetected
|
|
{
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary.
|
|
///
|
|
/// Without KKP (or an equivalent extended-key path), `Ctrl+.` is not a
|
|
/// classic C0 control and collapses to `.` / an ambiguous byte — so we
|
|
/// follow [`Self::kitty_skip_reason`] rather than a hard-coded brand
|
|
/// list. That keeps iTerm2+tmux with `extended-keys off` (and other
|
|
/// multiplexer skips) aligned with VS Code / VTE / Apple Terminal.
|
|
/// The pager folds in host-OS signals (Windows, WSL) via
|
|
/// `ctrl_dot_unreliable()`.
|
|
pub fn ctrl_dot_unreliable(&self) -> bool {
|
|
self.kitty_skip_reason().is_some()
|
|
}
|
|
|
|
/// Returns the reason to skip hyperlink (OSC 8) emission, or `None`
|
|
/// if the environment is compatible.
|
|
///
|
|
/// Terminal-emulator reasons take precedence over multiplexer reasons
|
|
/// so the user is pointed at the deeper cause.
|
|
pub fn hyperlink_skip_reason(&self) -> Option<&'static str> {
|
|
let caps = self.hyperlink_capabilities();
|
|
let is_tmux_3_4_later = self.is_tmux_version_or_later(3, 4);
|
|
if caps.osc8 == Osc8Support::HostileParser {
|
|
return Some("apple_terminal");
|
|
}
|
|
if caps.osc8 == Osc8Support::Unsupported {
|
|
return Some("unsupported_terminal");
|
|
}
|
|
// VTE < 0.50.4 (version int < 5004) does not handle OSC 8 cleanly.
|
|
// Checked before multiplexer so a VTE+old-tmux user is pointed at the
|
|
// VTE upgrade rather than chasing tmux config.
|
|
if let Some(ref vte_ver) = self.vte_version
|
|
&& let Ok(ver_int) = vte_ver.parse::<u32>()
|
|
&& ver_int < 5004
|
|
{
|
|
return Some("vte_old");
|
|
}
|
|
if caps.osc8 == Osc8Support::Unknown {
|
|
return Some("unknown_terminal");
|
|
}
|
|
if self.multiplexer == MultiplexerKind::Screen {
|
|
return Some("screen");
|
|
}
|
|
if self.multiplexer == MultiplexerKind::Tmux && !is_tmux_3_4_later {
|
|
return Some("tmux_old");
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Returns `true` if the detected tmux version is `major`.`minor` or later.
|
|
pub fn is_tmux_version_or_later(&self, major: u32, minor: u32) -> bool {
|
|
match self
|
|
.tmux_version
|
|
.as_deref()
|
|
.and_then(parse_tmux_major_minor)
|
|
{
|
|
Some((maj, min)) => (maj, min) >= (major, minor),
|
|
// Unknown version — conservative: assume old.
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if `TERM_PROGRAM_VERSION` is `major`.`minor` or later.
|
|
/// Returns `false` when the env var is absent or unparseable.
|
|
pub fn is_term_program_version_or_later(&self, major: u32, minor: u32) -> bool {
|
|
match self
|
|
.term_program_version
|
|
.as_deref()
|
|
.and_then(parse_semver_major_minor)
|
|
{
|
|
Some((maj, min)) => (maj, min) >= (major, minor),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// Extract a flat snapshot of terminal details for structured diagnostics
|
|
/// (local logs, input dumps).
|
|
pub fn diagnostics_snapshot(&self) -> TerminalDiagnostics {
|
|
let os = crate::host::HostOs::current();
|
|
let server = crate::host::DisplayServer::current();
|
|
let kb = self.keyboard_capabilities();
|
|
let route = crate::clipboard::clipboard_route();
|
|
TerminalDiagnostics {
|
|
brand: self.brand.to_string(),
|
|
multiplexer: self.multiplexer.to_string(),
|
|
is_ssh: self.is_ssh,
|
|
is_byobu: self.is_byobu(),
|
|
term_var: self.term_var_or_na().to_owned(),
|
|
host_os: os.to_string(),
|
|
display_server: server.to_string(),
|
|
modifier_cmd_fate: kb.modifier_delivery.cmd.to_string(),
|
|
modifier_opt_fate: kb.modifier_delivery.opt.to_string(),
|
|
enter_modifier_fate: kb.enter_modifier.to_string(),
|
|
tmux_version: self.tmux_version_or_na().to_owned(),
|
|
xtversion: xtversion::detected().unwrap_or("").to_owned(),
|
|
hyperlink_osc8: self.hyperlink_capabilities().osc8.to_string(),
|
|
hyperlink_skip_reason: self.hyperlink_skip_reason().unwrap_or("none").to_owned(),
|
|
clipboard_route: route.to_string(),
|
|
clipboard_native_tool: kigi_shared::clipboard::native_tool_name().to_owned(),
|
|
clipboard_data_control: crate::clipboard::wayland_data_control_label().to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Extract terminal info for feedback submissions.
|
|
pub fn feedback_info(&self) -> kigi_shared::session::FeedbackTerminalInfo {
|
|
use kigi_shared::session::FeedbackTerminalInfo;
|
|
// XTVERSION self-report lets feedback triage identify the terminal
|
|
// even when env detection failed (e.g. over SSH).
|
|
let brand = match xtversion::detected() {
|
|
Some(v) if self.brand == TerminalName::Unknown => format!("Unknown (XTVERSION: {v})"),
|
|
_ => self.brand.to_string(),
|
|
};
|
|
FeedbackTerminalInfo {
|
|
brand,
|
|
multiplexer: self.multiplexer.to_string(),
|
|
is_ssh: self.is_ssh,
|
|
is_byobu: self.is_byobu(),
|
|
term_var: self.term_var_or_na().to_owned(),
|
|
tmux_version: if self.is_tmux_backed() {
|
|
self.tmux_version.clone()
|
|
} else {
|
|
None
|
|
},
|
|
hyperlink_osc8_support: Some(self.hyperlink_capabilities().osc8.to_string()),
|
|
clipboard_route: Some(crate::clipboard::clipboard_route().to_string()),
|
|
clipboard_native_tool: Some(kigi_shared::clipboard::native_tool_name().to_owned()),
|
|
display_server: Some(crate::host::DisplayServer::current().to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
static TERMINAL_CONTEXT: OnceLock<TerminalContext> = OnceLock::new();
|
|
|
|
/// Returns the cached terminal context for the current process.
|
|
///
|
|
/// This is the preferred entry point for new code that needs multiplexer
|
|
/// or Byobu information. The context is computed once at first access from
|
|
/// process environment variables.
|
|
pub fn terminal_context() -> &'static TerminalContext {
|
|
TERMINAL_CONTEXT.get_or_init(detect_terminal_context)
|
|
}
|
|
|
|
/// Build a [`TerminalContext`] from the current process environment.
|
|
fn detect_terminal_context() -> TerminalContext {
|
|
let env = collect_process_env();
|
|
// NOTE: brand is usually Unknown in tmux (overwrites TERM_PROGRAM,
|
|
// per-pane vars don't survive) and over SSH (not forwarded), except
|
|
// brands with SSH-surviving markers (the VS Code family, and iTerm2
|
|
// via LC_TERMINAL). tmux -g global env is stale (reflects the server's
|
|
// first client, not the current one). Revisit when `kigi ssh` can
|
|
// forward env vars.
|
|
let mut ctx = build_terminal_context_from_env(&env);
|
|
ctx.brand = refine_unknown_brand_for_host(ctx.brand, HostOs::current());
|
|
if ctx.is_tmux_backed() {
|
|
ctx.tmux_version = detect_tmux_version();
|
|
ctx.tmux_extended_keys = detect_tmux_extended_keys();
|
|
}
|
|
ctx
|
|
}
|
|
|
|
/// Collect the process environment into a `HashMap` for the pure helpers.
|
|
fn collect_process_env() -> HashMap<String, String> {
|
|
crate::host::collect_unicode_env()
|
|
}
|
|
|
|
/// Helper to look up a key in the env map, returning `Some` for non-empty
|
|
/// values.
|
|
fn env_get<'a>(env: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
|
|
env.get(key).map(|v| v.as_str()).filter(|v| !v.is_empty())
|
|
}
|
|
|
|
/// Detect the terminal brand from an injected environment map.
|
|
///
|
|
/// This is the pure equivalent of the original `detect_terminal_info`.
|
|
///
|
|
/// Including a new env marker to this brand chain (or to
|
|
/// [`detect_byobu_from_env`] / [`detect_multiplexer_from_env`] below)
|
|
/// requires extending `HOST_TERMINAL_ENV_VARS` in
|
|
/// `kigi-pager-pty-harness/src/pty.rs` (test-env hygiene — the PTY
|
|
/// harness strips every marker read here so the host terminal can't leak
|
|
/// into tests).
|
|
pub fn detect_terminal_brand_from_env(env: &HashMap<String, String>) -> TerminalName {
|
|
// Some VS Code forks set TERM_PROGRAM=vscode, so check IDE-specific
|
|
// env vars first to disambiguate them from upstream VS Code.
|
|
//
|
|
// These markers also survive cases where TERM_PROGRAM does not: plain
|
|
// SSH (TERM_PROGRAM not forwarded) and tmux (TERM_PROGRAM overwritten
|
|
// by the multiplexer). Without them, brand falls back to Unknown and
|
|
// clipboard/keyboard gates that key off VS Code family miss the session.
|
|
if env_get(env, "CURSOR_TRACE_ID").is_some() {
|
|
return TerminalName::Cursor;
|
|
}
|
|
if let Some(askpass) = env_get(env, "VSCODE_GIT_ASKPASS_MAIN") {
|
|
let askpass_lower = askpass.to_ascii_lowercase();
|
|
if askpass_lower.contains("cursor") {
|
|
return TerminalName::Cursor;
|
|
}
|
|
if askpass_lower.contains("windsurf") {
|
|
return TerminalName::Windsurf;
|
|
}
|
|
// Pure VS Code remote agent injects this even without TERM_PROGRAM.
|
|
return TerminalName::VsCode;
|
|
}
|
|
|
|
// TERM_PROGRAM is the most reliable signal.
|
|
if let Some(term_program) = env_get(env, "TERM_PROGRAM")
|
|
&& let Some(name) = terminal_name_from_term_program(term_program)
|
|
{
|
|
return name;
|
|
}
|
|
|
|
// JetBrains IDE terminal (JediTerm). All JetBrains IDEs (IntelliJ,
|
|
// PhpStorm, WebStorm, etc.) set TERMINAL_EMULATOR=JetBrains-JediTerm.
|
|
// Both the Classic and Reworked 2025 engine set the same value — there
|
|
// is no env var to distinguish them, no TERM_FEATURES equivalent, and
|
|
// XTVERSION queries leak as garbage (DA1 returns bare VT102 `?6c`).
|
|
// We're effectively blind to capabilities; conservative defaults only.
|
|
//
|
|
// Must be checked before TERM_SESSION_ID: JetBrains sets that too
|
|
// (cross-platform, including Windows), which otherwise false-positives
|
|
// as Apple Terminal.
|
|
if let Some(te) = env_get(env, "TERMINAL_EMULATOR") {
|
|
let te_lower = te.to_ascii_lowercase();
|
|
if te_lower.contains("jetbrains") || te_lower.contains("jediterm") {
|
|
return TerminalName::JetBrains;
|
|
}
|
|
}
|
|
|
|
// WezTerm-specific variable.
|
|
if env_get(env, "WEZTERM_VERSION").is_some() {
|
|
return TerminalName::WezTerm;
|
|
}
|
|
|
|
// iTerm2. LC_TERMINAL=iTerm2 survives SSH (SendEnv/AcceptEnv LC_*)
|
|
// where ITERM_SESSION_ID / TERM_PROGRAM do not.
|
|
if env_get(env, "ITERM_SESSION_ID").is_some()
|
|
|| env_get(env, "ITERM_PROFILE").is_some()
|
|
|| env_get(env, "LC_TERMINAL").is_some_and(|v| v.eq_ignore_ascii_case("iterm2"))
|
|
{
|
|
return TerminalName::Iterm2;
|
|
}
|
|
|
|
// Apple Terminal.
|
|
if env_get(env, "TERM_SESSION_ID").is_some() {
|
|
return TerminalName::AppleTerminal;
|
|
}
|
|
|
|
// Kitty.
|
|
if env_get(env, "KITTY_WINDOW_ID").is_some() {
|
|
return TerminalName::Kitty;
|
|
}
|
|
if let Some(term) = env_get(env, "TERM")
|
|
&& term.contains("kitty")
|
|
{
|
|
return TerminalName::Kitty;
|
|
}
|
|
|
|
// Alacritty.
|
|
if env_get(env, "ALACRITTY_SOCKET").is_some() {
|
|
return TerminalName::Alacritty;
|
|
}
|
|
if let Some(term) = env_get(env, "TERM")
|
|
&& term == "alacritty"
|
|
{
|
|
return TerminalName::Alacritty;
|
|
}
|
|
|
|
// Rio.
|
|
if let Some(term) = env_get(env, "TERM")
|
|
&& term == "rio"
|
|
{
|
|
return TerminalName::Rio;
|
|
}
|
|
|
|
// foot (Wayland-native, Linux-only). foot sets no unique env var, only
|
|
// TERM. Match its exact terminfo names to avoid over-matching.
|
|
if let Some(term) = env_get(env, "TERM")
|
|
&& matches!(term, "foot" | "foot-extra" | "foot-direct")
|
|
{
|
|
return TerminalName::Foot;
|
|
}
|
|
|
|
// Terminator (Python/GTK, VTE-based) exports TERMINATOR_UUID on every
|
|
// child process. Check before the generic VTE_VERSION fallback so it is
|
|
// identified specifically rather than as a generic VTE terminal (it sets
|
|
// both).
|
|
if env_get(env, "TERMINATOR_UUID").is_some() {
|
|
return TerminalName::Terminator;
|
|
}
|
|
|
|
// VTE-based terminals (GNOME Terminal, kgx, Tilix, etc.).
|
|
if env_get(env, "VTE_VERSION").is_some() {
|
|
return TerminalName::Vte;
|
|
}
|
|
|
|
// Windows Terminal sets WT_SESSION (a GUID) on every child process.
|
|
if env_get(env, "WT_SESSION").is_some() {
|
|
return TerminalName::WindowsTerminal;
|
|
}
|
|
|
|
TerminalName::Unknown
|
|
}
|
|
|
|
/// Resolve an `Unknown` brand to `WindowsTerminal` on native Windows.
|
|
///
|
|
/// Windows Terminal is the Windows 11 default, but its DefTerm handoff
|
|
/// starts the first shell without WT_SESSION/TERM_PROGRAM
|
|
/// (microsoft/terminal#13006), so env detection misses it. The raw
|
|
/// detection stays in [`TerminalContext::env_brand`] for consumers that must
|
|
/// not trust this guess. WSL is unaffected (its Linux binary reports
|
|
/// `HostOs::Linux`).
|
|
fn refine_unknown_brand_for_host(brand: TerminalName, host: HostOs) -> TerminalName {
|
|
if brand == TerminalName::Unknown && host == HostOs::Windows {
|
|
TerminalName::WindowsTerminal
|
|
} else {
|
|
brand
|
|
}
|
|
}
|
|
|
|
/// Core predicate for [`TerminalContext::mouse_reporting_leaks_as_raw_text`],
|
|
/// split out so the brand/host matrix is unit-testable without the real host.
|
|
fn mouse_reporting_leaks(brand: TerminalName, host: HostOs) -> bool {
|
|
brand == TerminalName::JetBrains && host == HostOs::Windows
|
|
}
|
|
|
|
/// Detect the Byobu wrapper state from an injected environment map.
|
|
///
|
|
/// Returns `Some(ByobuBackend)` when Byobu markers are present and the
|
|
/// backend can be determined. Byobu sets `BYOBU_BACKEND` to `"tmux"` or
|
|
/// `"screen"`. When `BYOBU_BACKEND` is absent but other Byobu markers
|
|
/// exist (`BYOBU_CONFIG_DIR`, `BYOBU_DISTRO`), we infer the backend from
|
|
/// the multiplexer markers `TMUX` and `STY`.
|
|
pub fn detect_byobu_from_env(env: &HashMap<String, String>) -> Option<ByobuBackend> {
|
|
let has_byobu_backend = env_get(env, "BYOBU_BACKEND").is_some();
|
|
let has_byobu_config = env_get(env, "BYOBU_CONFIG_DIR").is_some();
|
|
let has_byobu_distro = env_get(env, "BYOBU_DISTRO").is_some();
|
|
|
|
if !has_byobu_backend && !has_byobu_config && !has_byobu_distro {
|
|
return None;
|
|
}
|
|
|
|
// Explicit backend marker takes precedence.
|
|
if let Some(backend) = env_get(env, "BYOBU_BACKEND") {
|
|
return match backend.to_ascii_lowercase().as_str() {
|
|
"tmux" => Some(ByobuBackend::Tmux),
|
|
"screen" => Some(ByobuBackend::Screen),
|
|
// Unknown backend string — fall through to inference.
|
|
_ => infer_byobu_backend_from_mux_markers(env),
|
|
};
|
|
}
|
|
|
|
// No explicit BYOBU_BACKEND but other markers present — infer.
|
|
infer_byobu_backend_from_mux_markers(env)
|
|
}
|
|
|
|
/// When Byobu markers exist but `BYOBU_BACKEND` is absent or unrecognised,
|
|
/// infer the backend from which multiplexer markers are set.
|
|
fn infer_byobu_backend_from_mux_markers(env: &HashMap<String, String>) -> Option<ByobuBackend> {
|
|
let has_tmux = env_get(env, "TMUX").is_some();
|
|
let has_sty = env_get(env, "STY").is_some();
|
|
|
|
match (has_tmux, has_sty) {
|
|
(true, _) => Some(ByobuBackend::Tmux),
|
|
(false, true) => Some(ByobuBackend::Screen),
|
|
// Byobu markers with neither TMUX nor STY — cannot determine.
|
|
(false, false) => None,
|
|
}
|
|
}
|
|
|
|
/// Detect the multiplexer kind from an injected environment map.
|
|
///
|
|
/// Precedence rules for ambiguous marker combinations:
|
|
/// 1. Explicit `BYOBU_BACKEND` beats generic `TMUX`/`STY` clues.
|
|
/// 2. `TMUX` beats `ZELLIJ` (tmux can nest inside Zellij but not vice-versa).
|
|
/// 3. `STY` (GNU screen) is only chosen when neither `TMUX` nor `ZELLIJ` is set.
|
|
/// 4. cmux markers classify only when no tmux/zellij/screen (or explicit
|
|
/// Byobu backend) won — so a real mux nested inside cmux still wins.
|
|
///
|
|
/// This ensures one deterministic classification even when multiple markers
|
|
/// are present (e.g., an inherited `ZELLIJ` var inside a tmux pane).
|
|
pub fn detect_multiplexer_from_env(env: &HashMap<String, String>) -> MultiplexerKind {
|
|
let byobu = detect_byobu_from_env(env);
|
|
|
|
// If Byobu is detected with an explicit backend, let that win.
|
|
if let Some(backend) = byobu {
|
|
match backend {
|
|
ByobuBackend::Tmux => return MultiplexerKind::Tmux,
|
|
ByobuBackend::Screen => return MultiplexerKind::Screen,
|
|
// fall through to standard markers
|
|
ByobuBackend::Unknown => {}
|
|
}
|
|
}
|
|
|
|
// Standard multiplexer markers, tmux > Zellij > screen.
|
|
// Nested real multiplexers inside cmux must win over cmux itself.
|
|
if env_get(env, "TMUX").is_some() {
|
|
return MultiplexerKind::Tmux;
|
|
}
|
|
if env_get(env, "ZELLIJ").is_some() || env_get(env, "ZELLIJ_SESSION_NAME").is_some() {
|
|
return MultiplexerKind::Zellij;
|
|
}
|
|
if env_get(env, "STY").is_some() {
|
|
return MultiplexerKind::Screen;
|
|
}
|
|
// cmux sets non-empty CMUX_SOCKET_PATH / CMUX_PANEL_ID / CMUX_BUNDLE_ID;
|
|
// CMUX_SOCKET may be present but empty — env_get filters empties.
|
|
if env_get(env, "CMUX_SOCKET_PATH").is_some()
|
|
|| env_get(env, "CMUX_PANEL_ID").is_some()
|
|
|| env_get(env, "CMUX_BUNDLE_ID").is_some()
|
|
{
|
|
return MultiplexerKind::Cmux;
|
|
}
|
|
|
|
MultiplexerKind::Undetected
|
|
}
|
|
|
|
/// Extract tmux client metadata from an injected environment map.
|
|
pub fn detect_tmux_meta_from_env(env: &HashMap<String, String>) -> TmuxClientMeta {
|
|
TmuxClientMeta {
|
|
tmux_env: env_get(env, "TMUX").map(|s| s.to_owned()),
|
|
tmux_pane: env_get(env, "TMUX_PANE").map(|s| s.to_owned()),
|
|
}
|
|
}
|
|
|
|
/// Build a full [`TerminalContext`] from an injected environment map.
|
|
///
|
|
/// This is the primary pure helper — call it with a controlled env map in
|
|
/// tests to exercise the full detection matrix without ambient host state.
|
|
pub fn build_terminal_context_from_env(env: &HashMap<String, String>) -> TerminalContext {
|
|
let brand = detect_terminal_brand_from_env(env);
|
|
let multiplexer = detect_multiplexer_from_env(env);
|
|
let byobu = detect_byobu_from_env(env);
|
|
let embedded_editor = embedded_editor_from_env(env);
|
|
let tmux_meta = if multiplexer == MultiplexerKind::Tmux {
|
|
detect_tmux_meta_from_env(env)
|
|
} else {
|
|
TmuxClientMeta::default()
|
|
};
|
|
let is_ssh = env_get(env, "SSH_CONNECTION").is_some()
|
|
|| env_get(env, "SSH_TTY").is_some()
|
|
|| env_get(env, "SSH_CLIENT").is_some();
|
|
let term_var = env_get(env, "TERM").map(|s| s.to_owned());
|
|
let vte_version = env_get(env, "VTE_VERSION").map(|s| s.to_owned());
|
|
// SSH strips TERM_PROGRAM_VERSION; iTerm2 LC_TERMINAL_VERSION survives.
|
|
let term_program_version = env_get(env, "TERM_PROGRAM_VERSION")
|
|
.or_else(|| env_get(env, "LC_TERMINAL_VERSION"))
|
|
.map(|s| s.to_owned());
|
|
|
|
TerminalContext {
|
|
brand,
|
|
env_brand: brand,
|
|
multiplexer,
|
|
byobu,
|
|
embedded_editor,
|
|
tmux_meta,
|
|
is_ssh,
|
|
term_var,
|
|
tmux_version: None,
|
|
vte_version,
|
|
tmux_extended_keys: None,
|
|
term_program_version,
|
|
}
|
|
}
|
|
|
|
fn terminal_name_from_term_program(value: &str) -> Option<TerminalName> {
|
|
let normalized: String = value
|
|
.trim()
|
|
.chars()
|
|
.filter(|c| !matches!(c, ' ' | '-' | '_' | '.'))
|
|
.map(|c| c.to_ascii_lowercase())
|
|
.collect();
|
|
|
|
match normalized.as_str() {
|
|
"appleterminal" => Some(TerminalName::AppleTerminal),
|
|
"ghostty" => Some(TerminalName::Ghostty),
|
|
"iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2),
|
|
"warp" | "warpterminal" => Some(TerminalName::WarpTerminal),
|
|
"vscode" => Some(TerminalName::VsCode),
|
|
"wezterm" => Some(TerminalName::WezTerm),
|
|
"kitty" => Some(TerminalName::Kitty),
|
|
"alacritty" => Some(TerminalName::Alacritty),
|
|
"rio" => Some(TerminalName::Rio),
|
|
"terminator" => Some(TerminalName::Terminator),
|
|
"zed" => Some(TerminalName::Zed),
|
|
"kigidesktop" => Some(TerminalName::KigiDesktop),
|
|
"windowsterminal" => Some(TerminalName::WindowsTerminal),
|
|
"otty" => Some(TerminalName::Otty),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// User-configured alt-screen (fullscreen) mode.
|
|
///
|
|
/// Parsed from `[terminal] alt_screen` in `~/.kigi/pager.toml` and
|
|
/// overridden by the `--no-alt-screen` CLI flag.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum AltScreenMode {
|
|
/// Automatic: fullscreen in plain terminals and normal tmux, inline in
|
|
/// tmux control mode and Zellij.
|
|
#[default]
|
|
Auto,
|
|
/// Always enter the alternate screen, even in environments where auto
|
|
/// would disable it (e.g. tmux control mode, Zellij).
|
|
Always,
|
|
/// Never enter the alternate screen — always run inline.
|
|
Never,
|
|
}
|
|
|
|
/// Detect whether the current tmux session is in control mode.
|
|
///
|
|
/// tmux control mode is detected by checking the `client_flags` on the
|
|
/// current client. In control mode, the flags contain "control-mode".
|
|
///
|
|
/// Returns `false` when not inside tmux or when the query fails.
|
|
pub fn detect_tmux_control_mode(ctx: &TerminalContext) -> bool {
|
|
if ctx.multiplexer != MultiplexerKind::Tmux {
|
|
return false;
|
|
}
|
|
detect_tmux_control_mode_subprocess()
|
|
}
|
|
|
|
/// Subprocess-based control-mode detection.
|
|
///
|
|
/// Queries `tmux display-message -p "#{client_flags}"` and checks whether
|
|
/// the output contains `"control-mode"`.
|
|
fn detect_tmux_control_mode_subprocess() -> bool {
|
|
let mut cmd = std::process::Command::new("tmux");
|
|
cmd.args(["display-message", "-p", "#{client_flags}"])
|
|
.stdin(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
|
cmd.output()
|
|
.ok()
|
|
.and_then(|out| {
|
|
if out.status.success() {
|
|
let flags = String::from_utf8_lossy(&out.stdout);
|
|
Some(flags.contains("control-mode"))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Detect the tmux server version by running `tmux -V`.
|
|
///
|
|
/// Returns the trimmed version string (e.g. `"tmux 3.4"`) on success,
|
|
/// or `None` when not inside tmux or when the subprocess fails.
|
|
/// Only call this when `ctx.is_tmux_backed()` is true.
|
|
pub fn detect_tmux_version() -> Option<String> {
|
|
let mut cmd = std::process::Command::new("tmux");
|
|
cmd.arg("-V")
|
|
.stdin(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
|
cmd.output().ok().and_then(|out| {
|
|
if out.status.success() {
|
|
let version = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
if version.is_empty() {
|
|
None
|
|
} else {
|
|
Some(version)
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Trimmed value of tmux's global `extended-keys` option, or `None` if
|
|
/// the subprocess fails or the option is unset / empty. Only call this
|
|
/// when `ctx.is_tmux_backed()` is true.
|
|
pub fn detect_tmux_extended_keys() -> Option<String> {
|
|
tmux_show_option("extended-keys")
|
|
}
|
|
|
|
/// Parse major.minor from a plain semver-ish string like `"3.6.0"` or `"1.2"`.
|
|
fn parse_semver_major_minor(version: &str) -> Option<(u32, u32)> {
|
|
let mut parts = version.split('.');
|
|
let major: u32 = parts.next()?.parse().ok()?;
|
|
let minor: u32 = parts.next()?.parse().ok()?;
|
|
Some((major, minor))
|
|
}
|
|
|
|
/// Parse the major.minor version from a tmux version string like `"tmux 3.4"`
|
|
/// or `"tmux 3.3a"`. Returns `None` on unrecognized formats.
|
|
fn parse_tmux_major_minor(version: &str) -> Option<(u32, u32)> {
|
|
let rest = version.strip_prefix("tmux ")?;
|
|
let mut parts = rest.split('.');
|
|
let major: u32 = parts.next()?.parse().ok()?;
|
|
let minor_str = parts.next()?;
|
|
let minor_end = minor_str
|
|
.find(|c: char| !c.is_ascii_digit())
|
|
.unwrap_or(minor_str.len());
|
|
let minor: u32 = minor_str[..minor_end].parse().ok()?;
|
|
Some((major, minor))
|
|
}
|
|
|
|
/// Resolve the effective alt-screen (fullscreen) state from CLI override,
|
|
/// config, and environment.
|
|
///
|
|
/// Precedence:
|
|
/// 1. `--no-alt-screen` CLI flag → always inline
|
|
/// 2. `config_mode` from `[terminal] alt_screen` → Always/Never/Auto
|
|
/// 3. Auto rules:
|
|
/// - Zellij → inline
|
|
/// - tmux control mode → inline
|
|
/// - otherwise → fullscreen
|
|
///
|
|
/// Returns `true` when the pager should enter the alternate screen.
|
|
pub fn determine_alt_screen_policy(
|
|
cli_no_alt_screen: bool,
|
|
config_mode: AltScreenMode,
|
|
ctx: &TerminalContext,
|
|
is_control_mode: bool,
|
|
) -> bool {
|
|
// CLI override has highest precedence.
|
|
if cli_no_alt_screen {
|
|
return false;
|
|
}
|
|
|
|
match config_mode {
|
|
AltScreenMode::Always => true,
|
|
AltScreenMode::Never => false,
|
|
AltScreenMode::Auto => {
|
|
// Auto-disable in Zellij.
|
|
if ctx.multiplexer == MultiplexerKind::Zellij {
|
|
return false;
|
|
}
|
|
// Auto-disable in tmux control mode.
|
|
if ctx.is_tmux_backed() && is_control_mode {
|
|
return false;
|
|
}
|
|
// Default: fullscreen.
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flat snapshot of the terminal environment for structured diagnostics.
|
|
///
|
|
/// Shared across local diagnostic records so terminal fields are typed once.
|
|
/// Constructed by [`TerminalContext::diagnostics_snapshot`].
|
|
#[derive(Clone, Debug, serde::Serialize)]
|
|
pub struct TerminalDiagnostics {
|
|
pub brand: String,
|
|
pub multiplexer: String,
|
|
pub is_ssh: bool,
|
|
pub is_byobu: bool,
|
|
pub term_var: String,
|
|
pub tmux_version: String,
|
|
pub xtversion: String,
|
|
pub host_os: String,
|
|
pub display_server: String,
|
|
pub modifier_cmd_fate: String,
|
|
pub modifier_opt_fate: String,
|
|
pub enter_modifier_fate: String,
|
|
pub hyperlink_osc8: String,
|
|
pub hyperlink_skip_reason: String,
|
|
pub clipboard_route: String,
|
|
pub clipboard_native_tool: String,
|
|
/// Wayland data-control protocol availability: "yes" | "no" | "n/a"
|
|
/// (n/a off Wayland).
|
|
pub clipboard_data_control: String,
|
|
}
|