M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,108 @@
//! Detects whether grok is running inside an editor's embedded `:terminal`
//! (Neovim/Vim `:terminal`, Emacs `vterm`).
//!
//! WHY this matters: inside an editor `:terminal` the *immediate* terminal
//! emulator is the editor's own libvterm, not tmux — even though the `TMUX`
//! env var is inherited through the editor. A tmux DCS passthrough envelope
//! (`\x1bPtmux;…\x1b\\`) is only understood by tmux, so emitting it into the
//! editor's libvterm renders the wrapper as visible garbage text. Detection
//! records this on [`super::TerminalContext`] so clipboard routing emits a
//! plain OSC 52 sequence in that case instead.
use std::collections::HashMap;
use super::env_get;
/// Which embedded editor `:terminal` grok is running inside.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EmbeddedEditor {
/// Neovim `:terminal` (sets `NVIM`, or legacy `NVIM_LISTEN_ADDRESS`).
Neovim,
/// Vim 8/9 `:terminal` (sets `VIM_TERMINAL`).
Vim,
/// Emacs (sets `INSIDE_EMACS`; `vterm` uses libvterm — same bug).
Emacs,
}
/// Detect the embedded editor terminal from an injected environment map.
///
/// Checks, in order: `NVIM` / `NVIM_LISTEN_ADDRESS` → [`EmbeddedEditor::Neovim`];
/// `VIM_TERMINAL` → [`EmbeddedEditor::Vim`]; `INSIDE_EMACS` →
/// [`EmbeddedEditor::Emacs`]; else `None`. Empty values are treated as absent
/// (matching the sibling `detect_*_from_env` detectors via `env_get`).
///
/// Adding a new env marker here requires extending
/// `HOST_TERMINAL_ENV_VARS` in `kigi-pager-pty-harness/src/pty.rs`
/// (test-env hygiene).
pub fn embedded_editor_from_env(env: &HashMap<String, String>) -> Option<EmbeddedEditor> {
// Markers can't distinguish editor-inside-tmux (the 100%-repro bug; don't wrap)
// from the inverted tmux-inside-editor; we target the former and the latter still
// works since plain OSC 52 is forwarded.
// NVIM_LISTEN_ADDRESS is legacy (modern nvim unsets it at startup); a
// stray/user-exported marker only degrades to plain OSC 52, never garbage.
if env_get(env, "NVIM").is_some() || env_get(env, "NVIM_LISTEN_ADDRESS").is_some() {
return Some(EmbeddedEditor::Neovim);
}
if env_get(env, "VIM_TERMINAL").is_some() {
return Some(EmbeddedEditor::Vim);
}
if env_get(env, "INSIDE_EMACS").is_some() {
return Some(EmbeddedEditor::Emacs);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::env_from;
#[test]
fn nvim_detected_as_neovim() {
let env = env_from(&[("NVIM", "/tmp/nvim.12345.0")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
#[test]
fn nvim_listen_address_detected_as_neovim() {
let env = env_from(&[("NVIM_LISTEN_ADDRESS", "/tmp/nvim.sock")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
#[test]
fn vim_terminal_detected_as_vim() {
let env = env_from(&[("VIM_TERMINAL", "8.2")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Vim));
}
#[test]
fn inside_emacs_detected_as_emacs() {
let env = env_from(&[("INSIDE_EMACS", "30.1,vterm")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Emacs));
}
#[test]
fn no_editor_markers_is_none() {
let env = env_from(&[
("TERM", "xterm-256color"),
("TMUX", "/tmp/tmux-501/default,1,0"),
]);
assert_eq!(embedded_editor_from_env(&env), None);
}
#[test]
fn empty_value_treated_as_absent() {
let env = env_from(&[("NVIM", ""), ("VIM_TERMINAL", ""), ("INSIDE_EMACS", "")]);
assert_eq!(embedded_editor_from_env(&env), None);
}
#[test]
fn nvim_beats_vim_and_emacs() {
let env = env_from(&[
("NVIM", "/tmp/nvim.12345.0"),
("VIM_TERMINAL", "8.2"),
("INSIDE_EMACS", "30.1,vterm"),
]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
}
@@ -0,0 +1,306 @@
//! Per-terminal hyperlink (OSC 8) capabilities.
//!
//! Classifies caller semantics so input-handling code can consume one struct
//! instead of branching on brand.
use super::TerminalName;
/// Whether the terminal supports OSC 8 hyperlink sequences.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum Osc8Support {
/// Terminal natively supports OSC 8 sequences.
Native,
/// Terminal actively garbles unknown OSC sequences (Apple Terminal).
HostileParser,
/// Terminal explicitly does not support OSC 8.
Unsupported,
/// Support status is unknown.
#[default]
Unknown,
}
/// Which URL schemes the terminal supports in OSC 8 links.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum SchemeFilter {
/// Standard web schemes: http, https, mailto.
#[default]
Standard,
/// Extended editor schemes: vscode://, cursor://, idea://, zed://.
EditorExtended,
}
impl SchemeFilter {
/// Returns `true` if the given scheme is permitted by this filter.
pub fn allows(&self, scheme: &str) -> bool {
match self {
Self::Standard => matches!(scheme, "http" | "https" | "mailto"),
Self::EditorExtended => matches!(
scheme,
"http" | "https" | "mailto" | "file" | "vscode" | "cursor" | "idea" | "zed"
),
}
}
}
/// Per-terminal hyperlink capabilities.
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct HyperlinkCapabilities {
/// OSC 8 support level.
pub osc8: Osc8Support,
/// Whether the terminal supports the `id=` parameter for hover-grouping.
pub id_param: bool,
/// Which URL schemes the terminal handles.
pub scheme_filter: SchemeFilter,
/// Whether the terminal supports OSC 22 cursor-shape changes
/// (e.g. switching to a hand/pointer cursor on link hover).
pub osc22_cursor: bool,
/// Whether the terminal handles link hover styling natively (so our
/// app should skip its own Cmd/Ctrl+hover highlight logic).
pub native_link_hover: bool,
/// Terminal opens bare http(s)/mailto under mouse reporting (Warp).
pub native_plain_url_open: bool,
}
/// Classify hyperlink capabilities for a given `brand`.
pub fn hyperlink_capabilities(brand: TerminalName) -> HyperlinkCapabilities {
use Osc8Support::*;
match brand {
// Apple Terminal actively garbles unknown OSC sequences.
TerminalName::AppleTerminal => HyperlinkCapabilities {
osc8: HostileParser,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Reference implementation. Excellent id= handling.
TerminalName::Iterm2 => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::Ghostty => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
// Since kitty v0.19.
TerminalName::Kitty => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
// Since Alacritty v0.11. Rio and foot also support OSC 8.
TerminalName::Alacritty | TerminalName::Rio | TerminalName::Foot => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::WezTerm => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// VS Code integrated terminal since v1.72. VS Code-family embeds
// inherit the same terminal renderer (xterm.js). Zed implements
// OSC 8 with similar capabilities. All of these handle link hover
// styling natively.
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: true,
native_plain_url_open: false,
},
// Open issue warpdotdev/Warp#4194. UrlLocator opens bare URLs under
// mouse reporting; keep native_link_hover false for file:// fallback.
TerminalName::WarpTerminal => HyperlinkCapabilities {
osc8: Unsupported,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: true,
},
// VTE-based terminals (GNOME Terminal, Terminator, etc.).
// Conservative -- gated by version in the route resolver if
// vte_version is too old.
TerminalName::Vte | TerminalName::Terminator => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Windows Terminal since v1.4 (OSC 8 support).
TerminalName::WindowsTerminal => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// JetBrains JediTerm: OSC 8 varies across IDE versions; no
// runtime probe available (no TERM_FEATURES). Conservative.
TerminalName::JetBrains => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Electron app; behavior undocumented.
TerminalName::GrokDesktop => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::Otty | TerminalName::Unknown => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
}
}
// ── OSC 22 cursor-shape commands ──────────────────────────────────────
//
// These wrap raw OSC 22 sequences as crossterm `Command`s so call sites
// can use `crossterm::execute!` / `queue!` instead of manual byte writes.
/// OSC 22: set the mouse pointer to the "pointer" (hand) shape.
///
/// Supported by iTerm2, Ghostty, and Kitty. Silently ignored by
/// terminals that don't understand OSC 22.
pub struct SetPointerCursor;
impl crossterm::Command for SetPointerCursor {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str("\x1b]22;pointer\x1b\\")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
Ok(())
}
}
/// OSC 22: reset the mouse pointer to the default (arrow) shape.
pub struct SetDefaultCursor;
impl crossterm::Command for SetDefaultCursor {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str("\x1b]22;default\x1b\\")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_terminal_hostile_parser() {
let caps = hyperlink_capabilities(TerminalName::AppleTerminal);
assert_eq!(caps.osc8, Osc8Support::HostileParser);
assert!(!caps.id_param);
}
#[test]
fn iterm2_native_with_id() {
let caps = hyperlink_capabilities(TerminalName::Iterm2);
assert_eq!(caps.osc8, Osc8Support::Native);
assert!(caps.id_param);
}
#[test]
fn warp_unsupported() {
let caps = hyperlink_capabilities(TerminalName::WarpTerminal);
assert_eq!(caps.osc8, Osc8Support::Unsupported);
assert!(!caps.id_param);
assert!(caps.native_plain_url_open);
assert!(!caps.native_link_hover);
}
#[test]
fn native_plain_url_open_only_warp() {
assert!(hyperlink_capabilities(TerminalName::WarpTerminal).native_plain_url_open);
for brand in [
TerminalName::Iterm2,
TerminalName::VsCode,
TerminalName::AppleTerminal,
TerminalName::Ghostty,
TerminalName::Unknown,
] {
assert!(
!hyperlink_capabilities(brand).native_plain_url_open,
"{brand:?} must not set native_plain_url_open"
);
}
}
#[test]
fn unknown_terminal_unknown_support() {
let caps = hyperlink_capabilities(TerminalName::Unknown);
assert_eq!(caps.osc8, Osc8Support::Unknown);
assert!(!caps.id_param);
}
#[test]
fn scheme_filter_standard_allows_http() {
assert!(SchemeFilter::Standard.allows("http"));
assert!(SchemeFilter::Standard.allows("https"));
assert!(SchemeFilter::Standard.allows("mailto"));
assert!(!SchemeFilter::Standard.allows("file"));
assert!(!SchemeFilter::Standard.allows("vscode"));
assert!(!SchemeFilter::Standard.allows("javascript"));
}
#[test]
fn scheme_filter_extended_allows_editor_schemes() {
assert!(SchemeFilter::EditorExtended.allows("http"));
assert!(SchemeFilter::EditorExtended.allows("vscode"));
assert!(SchemeFilter::EditorExtended.allows("cursor"));
assert!(SchemeFilter::EditorExtended.allows("idea"));
assert!(SchemeFilter::EditorExtended.allows("zed"));
assert!(!SchemeFilter::EditorExtended.allows("javascript"));
}
}
@@ -0,0 +1,651 @@
//! Terminal inline image rendering (Kitty / iTerm2 protocols).
//!
//! Provides escape-sequence helpers for rendering images inside the
//! existing preview overlay. The text-fallback path in
//! [`crate::render::image_overlay`] remains the primary preview; this
//! module adds pixel-level rendering for supported terminals.
//!
//! # Supported protocols
//!
//! - **Kitty graphics protocol**: used by Kitty, Ghostty, WezTerm, Warp
//! - **iTerm2 inline images**: helpers exist but are currently gated off in
//! [`protocol_for_brand()`] (see there for why); the text fallback is used
//! for iTerm2 instead.
//!
//! # Usage
//!
//! 1. Call [`detect_graphics_protocol()`] once (cached).
//! 2. During draw, if an image preview is active, call
//! [`render_kitty_image()`] or [`render_iterm2_image()`] to build the
//! escape sequence.
//! 3. Write the escape sequence to stderr **after** the ratatui cell
//! flush but inside the synchronized-output block.
//! 4. Coordinate shared ID-1 ownership through [`super::overlay`].
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use super::{TerminalName, terminal_context};
// -------------------------------------------------------------------------
// Graphics protocol detection
// -------------------------------------------------------------------------
/// Graphics protocol supported by the current terminal.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum GraphicsProtocol {
/// Kitty graphics protocol (also used by Ghostty, WezTerm).
Kitty,
/// iTerm2 inline images protocol.
ITerm2,
/// No graphics protocol available — text fallback only.
#[default]
None,
}
impl GraphicsProtocol {
/// Whether this protocol can render pixel images inline.
pub fn supports_images(self) -> bool {
!matches!(self, Self::None)
}
}
static GRAPHICS_PROTOCOL: OnceLock<GraphicsProtocol> = OnceLock::new();
/// When set, scrollback inline-media overlays are forced **off** process-wide,
/// regardless of the terminal's graphics capability. The scrollback-native
/// minimal mode (`grok --minimal`) sets this once at startup: it never runs the
/// interactive draw loop that paints inline images, so committed media blocks
/// must always fall back to the `[Open …]` text affordance — and must not
/// reserve blank image rows. See [`set_inline_overlay_force_off`].
static INLINE_OVERLAY_FORCE_OFF: AtomicBool = AtomicBool::new(false);
/// Force scrollback inline-media overlays off (`off = true`) or restore the
/// capability-based default (`off = false`) process-wide. Called once at
/// startup by the pager when minimal mode is active.
pub fn set_inline_overlay_force_off(off: bool) {
INLINE_OVERLAY_FORCE_OFF.store(off, Ordering::Relaxed);
}
/// Whether scrollback inline-media overlays are currently forced off — i.e. the
/// process is in minimal/scrollback-native mode, which commits static text and
/// never runs the interactive draw loop. Also used to suppress draw-loop-painted
/// affordances (e.g. the mermaid button row) that would otherwise commit blank.
pub fn scrollback_inline_overlay_forced_off() -> bool {
INLINE_OVERLAY_FORCE_OFF.load(Ordering::Relaxed)
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// Per-test override so tests don't depend on the host terminal or the
/// process-wide `GRAPHICS_PROTOCOL` cache.
static TEST_PROTOCOL_OVERRIDE: std::cell::Cell<Option<GraphicsProtocol>> =
const { std::cell::Cell::new(None) };
}
/// Detect and cache the graphics protocol for the current terminal.
pub fn detect_graphics_protocol() -> GraphicsProtocol {
#[cfg(any(test, feature = "test-support"))]
if let Some(p) = TEST_PROTOCOL_OVERRIDE.with(|c| c.get()) {
return p;
}
*GRAPHICS_PROTOCOL.get_or_init(|| {
let ctx = terminal_context();
if ctx.graphics_protocol_skip_reason().is_some() {
return GraphicsProtocol::None;
}
protocol_for_brand(ctx.brand, cfg!(target_os = "windows"))
})
}
/// Whether the current terminal can safely host scrollback inline-media
/// overlays.
///
/// This is narrower than "supports Kitty graphics": scrollback media uses
/// Kitty image ids, placement ids, z-index, clearing, and source cropping so
/// images scroll with the text grid. Warp accepts some Kitty image escapes but
/// does not reliably support that placement/scrollback model, which leaves
/// stale or corrupted pixels while scrolling.
pub fn scrollback_inline_overlay_active() -> bool {
// Minimal mode forces this off process-wide: it never paints inline images,
// so media must always use the text affordance.
if INLINE_OVERLAY_FORCE_OFF.load(Ordering::Relaxed) {
return false;
}
let protocol = detect_graphics_protocol();
if test_protocol_override_active() {
return protocol == GraphicsProtocol::Kitty;
}
scrollback_inline_overlay_active_for_brand(protocol, terminal_context().brand)
}
#[cfg(any(test, feature = "test-support"))]
fn test_protocol_override_active() -> bool {
TEST_PROTOCOL_OVERRIDE.with(|c| c.get().is_some())
}
#[cfg(not(any(test, feature = "test-support")))]
fn test_protocol_override_active() -> bool {
false
}
/// Pure capability helper for scrollback inline-media overlays.
fn scrollback_inline_overlay_active_for_brand(
protocol: GraphicsProtocol,
brand: TerminalName,
) -> bool {
matches!(
(protocol, brand),
(
GraphicsProtocol::Kitty,
TerminalName::Kitty | TerminalName::Ghostty | TerminalName::WezTerm
)
)
}
/// Set a per-thread protocol override for tests. Returns a guard that
/// clears it on drop.
#[cfg(any(test, feature = "test-support"))]
pub fn set_protocol_for_test(p: GraphicsProtocol) -> TestProtocolGuard {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(Some(p)));
TestProtocolGuard
}
/// RAII guard that clears the test protocol override on drop.
#[cfg(any(test, feature = "test-support"))]
pub struct TestProtocolGuard;
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestProtocolGuard {
fn drop(&mut self) {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(None));
}
}
/// Map terminal brand to graphics protocol. Returns `None` on Windows
/// because ConPTY strips the Kitty/iTerm2 APC escape sequences before
/// they reach the host terminal.
///
/// Parameterised by `is_windows` so unit tests can exercise both paths
/// on any OS.
pub fn protocol_for_brand(brand: TerminalName, is_windows: bool) -> GraphicsProtocol {
if is_windows {
return GraphicsProtocol::None;
}
match brand {
TerminalName::Kitty => GraphicsProtocol::Kitty,
TerminalName::Ghostty => GraphicsProtocol::Kitty,
TerminalName::WezTerm => GraphicsProtocol::Kitty,
TerminalName::WarpTerminal => GraphicsProtocol::Kitty,
// iTerm2's OSC 1337 inline-image protocol lacks the image-id, z-index,
// source-crop, and clear primitives the Kitty protocol has, so overlay
// images don't track the text grid — they paint wrong or never appear
// (leaving a stuck "Loading…" hint). Disable it; the text/metadata
// fallback is used instead. Re-enable with `ITerm2` once verified.
TerminalName::Iterm2 => GraphicsProtocol::None,
_ => GraphicsProtocol::None,
}
}
// -------------------------------------------------------------------------
// Kitty graphics protocol
// -------------------------------------------------------------------------
/// Shared placement ID; every renderer must coordinate through [`super::overlay`].
pub(super) const KITTY_PLACEMENT_ID: u32 = 1;
/// Kitty graphics protocol image format identifier.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KittyImageFormat {
/// PNG image data (`f=100`).
Png,
}
impl KittyImageFormat {
fn code(self) -> u16 {
match self {
Self::Png => 100,
}
}
}
/// Detect the Kitty graphics format code for encoded image bytes.
pub fn kitty_format_from_bytes(image_data: &[u8]) -> Option<KittyImageFormat> {
match kigi_shared::clipboard::mime_from_bytes(image_data) {
"image/png" => Some(KittyImageFormat::Png),
_ => None,
}
}
/// Whether Kitty can directly render this encoded MIME type in raw-byte mode.
pub fn kitty_mime_is_directly_supported(mime_type: &str) -> bool {
mime_type == "image/png"
}
/// Prepare encoded image bytes for Kitty's raw-byte overlay path.
///
/// Kitty accepts encoded PNG bytes via `f=100`, but not encoded JPEG/WebP/etc.
/// Convert other decodable images to PNG before handing them to the centered
/// overlay renderer. Callers must keep this out of draw paths.
///
/// On macOS, uses `sips` (Apple CoreGraphics) which handles ICC colour
/// profiles correctly. Falls back to the `image` crate on other platforms.
pub fn prepare_kitty_overlay_image_bytes(image_data: &[u8]) -> Option<Vec<u8>> {
if kitty_format_from_bytes(image_data).is_some() {
return Some(image_data.to_vec());
}
// On macOS, convert via `sips` through a temp file. CoreGraphics
// handles ICC colour profiles correctly, avoiding the artifacts
// that the `image` crate's JPEG→PNG path can produce.
if cfg!(target_os = "macos")
&& let Some(png) = convert_via_sips(image_data)
{
return Some(png);
}
let img = image::ImageReader::new(std::io::Cursor::new(image_data))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let mut png = Vec::new();
{
use image::ExtendedColorType;
use image::ImageEncoder;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
let rgba = img.to_rgba8();
let encoder =
PngEncoder::new_with_quality(&mut png, CompressionType::Fast, FilterType::Adaptive);
encoder
.write_image(
rgba.as_raw(),
rgba.width(),
rgba.height(),
ExtendedColorType::Rgba8,
)
.ok()?;
}
Some(png)
}
/// Convert image bytes to PNG via macOS `sips` using temp files.
fn convert_via_sips(image_data: &[u8]) -> Option<Vec<u8>> {
use std::io::Write;
let tmp_dir = std::env::temp_dir();
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let src = tmp_dir.join(format!("grok-sips-{id}-{ts}.dat"));
let dst = tmp_dir.join(format!("grok-sips-{id}-{ts}.png"));
// Write source bytes to temp file.
let mut f = std::fs::File::create(&src).ok()?;
f.write_all(image_data).ok()?;
f.sync_all().ok()?;
drop(f);
let mut sips_cmd = std::process::Command::new("sips");
sips_cmd
.args(["-s", "format", "png"])
.arg(&src)
.arg("--out")
.arg(&dst)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tty_utils::detach_std_command(&mut sips_cmd);
let status = sips_cmd.status().ok()?;
let _ = std::fs::remove_file(&src);
if !status.success() || !dst.is_file() {
let _ = std::fs::remove_file(&dst);
return None;
}
let png = std::fs::read(&dst).ok()?;
let _ = std::fs::remove_file(&dst);
Some(png)
}
/// Prepare encoded image bytes for the currently detected overlay protocol.
pub fn prepare_overlay_image_bytes(image_data: &[u8]) -> Option<Vec<u8>> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => prepare_kitty_overlay_image_bytes(image_data),
GraphicsProtocol::ITerm2 => Some(image_data.to_vec()),
GraphicsProtocol::None => None,
}
}
/// Build a Kitty graphics protocol escape sequence to display encoded image data.
///
/// The image is transmitted inline as base64-encoded data and scaled by the
/// terminal to fit `cols` columns × `rows` rows. The terminal handles
/// HiDPI/Retina scaling correctly since it knows the actual cell pixel
/// dimensions.
///
/// Uses `a=T` (transmit + display), `f=<format>` (PNG format), `t=d`
/// (direct data transmission), `q=2` (suppress responses), `C=1` (preserve
/// cursor position), and `z=1` (draw above text cells), chunked into 4096-byte
/// pieces.
pub fn render_kitty_image(
image_data: &[u8],
format: KittyImageFormat,
cols: u16,
rows: u16,
) -> String {
render_kitty_image_z(image_data, format, cols, rows, 1)
}
/// Render a Kitty image with a specific z-index.
///
/// `z=1`: above text (modal overlays). `z=-1`: below text, above
/// background (inline scrollback media — dropdowns render on top).
pub fn render_kitty_image_z(
image_data: &[u8],
format: KittyImageFormat,
cols: u16,
rows: u16,
z: i32,
) -> String {
let header = format!(
"a=T,f={},t=d,q=2,C=1,z={},i={},p={},c={},r={}",
format.code(),
z,
KITTY_PLACEMENT_ID,
KITTY_PLACEMENT_ID,
cols,
rows,
);
kitty_chunked_escape(image_data, &header)
}
/// Transmit image data to the terminal without displaying it (`a=t`).
/// Use `place_kitty_image` to display it at a position.
pub fn transmit_kitty_image(image_data: &[u8], format: KittyImageFormat, image_id: u32) -> String {
let header = format!("a=t,f={},t=d,q=2,i={}", format.code(), image_id);
kitty_chunked_escape(image_data, &header)
}
/// Encode image data as chunked Kitty escape sequences.
/// `first_chunk_header` is the metadata for the first chunk (action, format, etc.).
fn kitty_chunked_escape(image_data: &[u8], first_chunk_header: &str) -> String {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(image_data);
let chunk_size = 4096;
let chunks: Vec<&str> = b64
.as_bytes()
.chunks(chunk_size)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
let mut out = String::new();
for (i, chunk) in chunks.iter().enumerate() {
let is_last = i == chunks.len() - 1;
let m = if is_last { 0 } else { 1 };
if i == 0 {
out.push_str(&format!("\x1b_G{first_chunk_header},m={m};{chunk}\x1b\\"));
} else {
out.push_str(&format!("\x1b_Gq=2,m={m};{chunk}\x1b\\"));
}
}
out
}
/// Place an already-transmitted image at the cursor position (`a=p`).
///
/// Tiny escape (~50 bytes) — no image data, just placement metadata.
pub fn place_kitty_image(image_id: u32, cols: u16, rows: u16, z: i32) -> String {
format!(
"\x1b_Ga=p,i={},p={},c={},r={},z={},C=1,q=2\x1b\\",
image_id, image_id, cols, rows, z,
)
}
/// Place an already-transmitted image with source cropping (`a=p`).
///
/// `src_x, src_y, src_w, src_h`: pixel region of the source image to display.
#[allow(clippy::too_many_arguments)]
pub fn place_kitty_image_cropped(
image_id: u32,
cols: u16,
rows: u16,
z: i32,
src_x: u32,
src_y: u32,
src_w: u32,
src_h: u32,
) -> String {
format!(
"\x1b_Ga=p,i={},p={},c={},r={},z={},x={},y={},w={},h={},C=1,q=2\x1b\\",
image_id, image_id, cols, rows, z, src_x, src_y, src_w, src_h,
)
}
/// Build a Kitty escape sequence to delete a specific image by ID.
pub fn clear_kitty_image(image_id: u32) -> String {
format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", image_id)
}
// -------------------------------------------------------------------------
// iTerm2 inline images protocol
// -------------------------------------------------------------------------
/// Build an iTerm2 inline image escape sequence.
///
/// Uses `\x1b]1337;File=inline=1;width=Ncells;height=Ncells;preserveAspectRatio=1:BASE64\x07`.
pub fn render_iterm2_image(image_data: &[u8], cols: u16, rows: u16) -> String {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(image_data);
format!(
"\x1b]1337;File=inline=1;width={cols}cells;height={rows}cells;preserveAspectRatio=1:{b64}\x07",
cols = cols,
rows = rows,
b64 = b64,
)
}
// -------------------------------------------------------------------------
// Shared overlay helpers
// -------------------------------------------------------------------------
/// Build the full escape-sequence string to render image data at a cell
/// position using the provided graphics protocol.
///
/// For Kitty: transmits image data once (`a=t`) then places it (`a=p`). Pass
/// `retransmit = false` on subsequent frames to emit only the placement escape
/// (~50 bytes) instead of re-uploading the full image every redraw.
///
/// For iTerm2: always emits the full inline image escape (no separate transmit
/// primitive). Callers should pass `retransmit = false` after the first frame
/// to avoid re-decoding the same image every tick.
///
/// Returns `None` when no graphics protocol is available.
///
/// Pass `retransmit = false` on subsequent frames to skip the data upload
/// (Kitty: place-only; iTerm2: no-op).
#[allow(clippy::too_many_arguments)]
pub(super) fn build_overlay_image_escapes_for_protocol(
protocol: GraphicsProtocol,
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
retransmit: bool,
) -> Option<String> {
if protocol == GraphicsProtocol::None {
return None;
}
let mut esc = String::new();
// ANSI cursor positioning is 1-based.
esc.push_str(&format!("\x1b[{};{}H", cell_y + 1, cell_x + 1));
match protocol {
GraphicsProtocol::Kitty => {
if retransmit {
// Transmit once, then place — never use a=T (transmit+display)
// on every frame; that re-uploads the full image at ~10fps and
// balloons native GPU surface counts in long-lived sessions.
// Place-only frames do not need image bytes / format detection.
let format = kitty_format_from_bytes(image_data)?;
esc.push_str(&transmit_kitty_image(
image_data,
format,
KITTY_PLACEMENT_ID,
));
}
esc.push_str(&place_kitty_image(
KITTY_PLACEMENT_ID,
cols,
rows,
1, // above text (modal overlays)
));
}
GraphicsProtocol::ITerm2 => {
if retransmit {
esc.push_str(&render_iterm2_image(image_data, cols, rows));
}
}
GraphicsProtocol::None => unreachable!(),
}
Some(esc)
}
/// Transmit inline image data to the terminal GPU.
///
/// Kitty: uploads with the given `image_id`. iTerm2: no-op (data sent per-place).
pub fn transmit_inline_image(image_data: &[u8], image_id: u32) -> Option<String> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => {
let format = kitty_format_from_bytes(image_data)?;
Some(transmit_kitty_image(image_data, format, image_id))
}
GraphicsProtocol::ITerm2 => Some(String::new()),
GraphicsProtocol::None => None,
}
}
/// Place an inline image at a position, optionally cropping.
///
/// For Kitty: ~80 bytes (no image data, just placement with crop).
/// For iTerm2: sends full image data only when `emit_iterm_data` is true
/// (no crop support). Pass `false` after the first placement to avoid
/// re-decoding the same image on every TUI frame.
#[allow(clippy::too_many_arguments)]
pub fn place_inline_image(
image_data: &[u8],
img_w: u32,
img_h: u32,
area: ratatui::layout::Rect,
full_rows: u16,
top_crop_rows: u16,
image_id: u32,
emit_iterm_data: bool,
) -> Option<String> {
let protocol = detect_graphics_protocol();
if protocol == GraphicsProtocol::None {
return None;
}
// Compute fit dimensions as if the full image were visible.
let (fit_cols, fit_rows) = fit_image_to_cells(img_w, img_h, area.width, full_rows);
let pad_x = area.width.saturating_sub(fit_cols) / 2;
let img_x = area.x + pad_x;
let img_y = area.y;
let mut esc = String::new();
esc.push_str(&format!("\x1b[{};{}H", img_y + 1, img_x + 1));
match protocol {
GraphicsProtocol::Kitty => {
let visible_rows = area.height.min(fit_rows);
if top_crop_rows > 0 || visible_rows < fit_rows {
let src_y = if fit_rows > 0 {
(top_crop_rows as u32 * img_h) / fit_rows as u32
} else {
0
};
let src_h = if fit_rows > 0 {
(visible_rows as u32 * img_h) / fit_rows as u32
} else {
img_h
};
esc.push_str(&place_kitty_image_cropped(
image_id,
fit_cols,
visible_rows,
-1,
0,
src_y,
img_w,
src_h.max(1),
));
} else {
esc.push_str(&place_kitty_image(image_id, fit_cols, fit_rows, -1));
}
}
GraphicsProtocol::ITerm2 => {
if emit_iterm_data {
esc.push_str(&render_iterm2_image(image_data, fit_cols, area.height));
}
}
GraphicsProtocol::None => unreachable!(),
}
Some(esc)
}
/// Compute the cell dimensions (`cols`, `rows`) to display an image at
/// its correct aspect ratio within a bounding box of `max_cols × max_rows`.
///
/// Terminal cells are not square — they're roughly twice as tall as wide
/// (typical monospace cell ~8px wide × ~16px tall, ratio ≈ 0.5). This
/// function accounts for that so a 1:1 image appears visually square
/// and a 16:9 screenshot looks like a 16:9 rectangle.
pub fn fit_image_to_cells(img_w: u32, img_h: u32, max_cols: u16, max_rows: u16) -> (u16, u16) {
if img_w == 0 || img_h == 0 || max_cols == 0 || max_rows == 0 {
return (max_cols.max(1), max_rows.max(1));
}
// Cell aspect ratio: width / height. Typical monospace cell is ~0.5
// (half as wide as tall). This converts between pixel-space and
// cell-space so the image doesn't appear stretched.
let cell_aspect: f64 = 0.5;
// Image aspect ratio in pixel space.
let img_aspect = img_w as f64 / img_h as f64;
// Convert image aspect to cell-space: how many columns per row the
// image needs to look correct. A cell is `cell_aspect` times as wide
// as it is tall, so we divide by cell_aspect.
// cols_per_row = img_aspect / cell_aspect
let cols_per_row = img_aspect / cell_aspect;
// Try fitting by width first.
let cols_by_width = max_cols;
let rows_by_width = (cols_by_width as f64 / cols_per_row).round() as u16;
// Try fitting by height.
let rows_by_height = max_rows;
let cols_by_height = (rows_by_height as f64 * cols_per_row).round() as u16;
// Pick whichever fit stays within bounds.
if rows_by_width <= max_rows {
(cols_by_width, rows_by_width.max(1))
} else {
(cols_by_height.min(max_cols).max(1), rows_by_height)
}
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests;
@@ -0,0 +1,121 @@
use super::*;
#[test]
fn protocol_matrix_matches_supported_terminals() {
for (brand, expected) in [
(TerminalName::Kitty, GraphicsProtocol::Kitty),
(TerminalName::Ghostty, GraphicsProtocol::Kitty),
(TerminalName::WezTerm, GraphicsProtocol::Kitty),
(TerminalName::WarpTerminal, GraphicsProtocol::Kitty),
(TerminalName::Iterm2, GraphicsProtocol::None),
(TerminalName::Unknown, GraphicsProtocol::None),
] {
assert_eq!(protocol_for_brand(brand, false), expected);
assert_eq!(protocol_for_brand(brand, true), GraphicsProtocol::None);
}
}
#[test]
fn scrollback_overlay_excludes_warp() {
assert!(scrollback_inline_overlay_active_for_brand(
GraphicsProtocol::Kitty,
TerminalName::Kitty,
));
assert!(!scrollback_inline_overlay_active_for_brand(
GraphicsProtocol::Kitty,
TerminalName::WarpTerminal,
));
}
#[test]
#[serial_test::serial]
fn force_off_overrides_capability() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
set_inline_overlay_force_off(false);
assert!(scrollback_inline_overlay_active());
set_inline_overlay_force_off(true);
assert!(!scrollback_inline_overlay_active());
set_inline_overlay_force_off(false);
}
#[test]
fn kitty_escape_chunks_and_preserves_cursor() {
let small = render_kitty_image(&[0u8; 10], KittyImageFormat::Png, 40, 20);
assert!(small.contains("a=T"));
assert!(small.contains("f=100"));
assert!(small.contains("q=2"));
assert!(small.contains("C=1"));
assert!(small.contains("c=40"));
assert!(small.contains("r=20"));
assert!(small.contains("m=0"));
let large = render_kitty_image(&vec![0u8; 5000], KittyImageFormat::Png, 40, 20);
assert!(large.matches("\x1b_G").count() > 1);
}
#[test]
fn kitty_format_and_conversion_produce_png() {
use image::{ImageBuffer, Rgb};
let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
assert_eq!(kitty_format_from_bytes(&png), Some(KittyImageFormat::Png));
let buffer: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(4, 3, Rgb([128, 64, 32]));
let mut jpeg = Vec::new();
buffer
.write_to(
&mut std::io::Cursor::new(&mut jpeg),
image::ImageFormat::Jpeg,
)
.unwrap();
assert_eq!(kitty_format_from_bytes(&jpeg), None);
let converted = prepare_kitty_overlay_image_bytes(&jpeg).unwrap();
assert_eq!(
kitty_format_from_bytes(&converted),
Some(KittyImageFormat::Png)
);
}
#[test]
fn iterm_escape_preserves_requested_geometry() {
let escape = render_iterm2_image(&[0u8; 10], 30, 15);
assert!(escape.starts_with("\x1b]1337;File="));
assert!(escape.contains("width=30cells"));
assert!(escape.contains("height=15cells"));
assert!(escape.contains("preserveAspectRatio=1"));
}
#[test]
fn low_level_overlay_separates_transmit_from_placement() {
let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
let protocol = GraphicsProtocol::Kitty;
let first =
build_overlay_image_escapes_for_protocol(protocol, &png, 20, 10, 0, 0, true).unwrap();
let subsequent =
build_overlay_image_escapes_for_protocol(protocol, &png, 20, 10, 0, 0, false).unwrap();
assert!(first.contains("a=t") && first.contains("a=p"));
assert!(!first.contains("a=T"));
assert!(subsequent.contains("a=p"));
assert!(!subsequent.contains("a=t"));
}
#[test]
fn iterm_place_can_skip_inline_data() {
let _guard = set_protocol_for_test(GraphicsProtocol::ITerm2);
let area = ratatui::layout::Rect::new(0, 0, 40, 20);
let escape = place_inline_image(&[0u8; 10], 100, 50, area, 20, 0, 2, false).unwrap();
assert!(escape.starts_with("\x1b["));
assert!(!escape.contains("1337"));
}
#[test]
fn placement_only_steady_state_removes_payload_cost() {
let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
png.extend(std::iter::repeat_n(0u8, 200_000));
let protocol = GraphicsProtocol::Kitty;
let first =
build_overlay_image_escapes_for_protocol(protocol, &png, 40, 20, 0, 0, true).unwrap();
let subsequent =
build_overlay_image_escapes_for_protocol(protocol, &png, 40, 20, 0, 0, false).unwrap();
assert!(first.len() > 200_000);
assert!(subsequent.len() < 200);
assert!(!subsequent.contains("a=t"));
}
@@ -0,0 +1,215 @@
//! Per-terminal keyboard input capabilities.
//!
//! Classifies keyboard delivery semantics so input-handling code can consume one struct
//! instead of branching on brand. The classification depends on the
//! current `HostOs`, queried internally — today only macOS rows are
//! populated. Extend [`KeyboardCapabilities`] with new fields (paste
//! protocol, focus reporting, custom escapes) instead of adding more
//! `match self.brand` sites scattered through the pager.
use super::TerminalName;
use crate::host::HostOs;
/// What happens to a single modifier (Cmd, Opt, etc.) on its way from
/// the keyboard to the program.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum ModifierFate {
/// Terminal delivers the modifier in the `KeyEvent` (KKP) or as a
/// readline-equivalent byte sequence the textarea already handles
/// (`^U`, `ESC ^?`).
Native,
/// Terminal drops the modifier; the OS-level rescue can recover it
/// (CoreGraphics on macOS).
Dropped,
/// Chord captured before reaching the PTY (Apple Terminal Cmd+Bsp).
/// No event arrives; not even an OS rescue helps.
Unrecoverable,
/// Behavior unclassified — treated as no-rescue to avoid false
/// positives on unknown brands.
#[default]
Unknown,
}
impl ModifierFate {
pub fn benefits_from_rescue(self) -> bool {
matches!(self, Self::Dropped)
}
}
/// How a terminal delivers Cmd/Opt-modified Backspace/Delete.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct ModifierDelivery {
pub cmd: ModifierFate,
pub opt: ModifierFate,
}
impl ModifierDelivery {
/// Construct a delivery from explicit fates. `#[non_exhaustive]` blocks
/// struct-literal construction from other crates, so downstream test
/// builds use this constructor.
#[cfg(any(test, feature = "test-support"))]
pub fn new_for_test(cmd: ModifierFate, opt: ModifierFate) -> Self {
Self { cmd, opt }
}
pub fn benefits_from_rescue(self) -> bool {
self.cmd.benefits_from_rescue() || self.opt.benefits_from_rescue()
}
pub fn label(self) -> String {
format!("cmd={}, opt={}", self.cmd, self.opt)
}
}
/// Per-terminal keyboard capabilities. Extend with new fields as more
/// per-terminal input behaviors get classified.
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct KeyboardCapabilities {
pub modifier_delivery: ModifierDelivery,
/// Fate of Shift/Opt/Cmd when modifying `Enter`. Apple Terminal
/// drops these and we recover them via the same OS poll used for
/// Backspace/Delete.
pub enter_modifier: ModifierFate,
}
impl KeyboardCapabilities {
pub fn enter_needs_rescue(&self) -> bool {
matches!(self.enter_modifier, ModifierFate::Dropped)
}
}
/// Classify keyboard capabilities for a given `(brand, os, display_server)`.
///
/// Today the table is populated only for macOS; other OSes return the
/// default (all-`Unknown`). When a Linux/Windows probe lands, add a
/// per-OS arm here rather than forking the function.
pub fn keyboard_capabilities(brand: TerminalName) -> KeyboardCapabilities {
match HostOs::current() {
HostOs::Macos => macos_capabilities(brand),
HostOs::Linux | HostOs::Windows | HostOs::Other => KeyboardCapabilities::default(),
}
}
fn macos_capabilities(brand: TerminalName) -> KeyboardCapabilities {
use ModifierFate::*;
let (cmd, opt, enter) = match brand {
TerminalName::Ghostty | TerminalName::Kitty | TerminalName::Foot => {
(Native, Native, Native)
}
// iTerm2/VS Code translate Cmd+Bsp → ^U and Opt+Bsp → ESC ^?,
// both of which the textarea already handles natively. VS Code-family
// embeds and Zed inherit the same keymap behavior as VS Code
// capabilities at runtime (no TERM_FEATURES, XTVERSION leaks).
// (including the Cmd+Bsp → ^U translation).
TerminalName::Iterm2
| TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed => (Native, Native, Native),
TerminalName::WezTerm => (Dropped, Native, Native),
// Alacritty's macOS keymap binds Cmd+Bsp → ^U (native readline);
// Opt+Bsp is a bare ^? without `option_as_alt` set.
TerminalName::Alacritty | TerminalName::Rio => (Native, Dropped, Native),
TerminalName::WarpTerminal => (Dropped, Dropped, Native),
// Apple Terminal: Cmd+Bsp captured by the window manager.
// Opt+Bsp and modified Enter are dropped; CG can rescue both.
TerminalName::AppleTerminal => (Unrecoverable, Dropped, Dropped),
TerminalName::GrokDesktop => (Unknown, Unknown, Unknown),
// VTE-based terminals (incl. Terminator) on macOS are unusual;
// classify when we have evidence rather than guessing.
TerminalName::Vte | TerminalName::Terminator => (Unknown, Unknown, Unknown),
// JetBrains JediTerm: no KKP, no CG rescue. No way to probe
// Mouse reporting has known SGR bugs in Classic engine (IJPL-232482);
// Mouse reporting has known SGR bugs in Classic engine (IJPL-232482);
// Reworked 2025 engine is better but indistinguishable via env vars.
TerminalName::JetBrains => (Unknown, Unknown, Unknown),
TerminalName::WindowsTerminal | TerminalName::Otty | TerminalName::Unknown => {
(Unknown, Unknown, Unknown)
}
};
KeyboardCapabilities {
modifier_delivery: ModifierDelivery { cmd, opt },
enter_modifier: enter,
}
}
// Tests verify the macOS classification table; they only mean
// something on a macOS host. Cross-OS verification will need a test
// harness that overrides `HostOs::current()`; see the module doc.
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn ghostty_and_kitty_native_no_rescue() {
for brand in [TerminalName::Ghostty, TerminalName::Kitty] {
let c = keyboard_capabilities(brand);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Native);
assert!(!c.modifier_delivery.benefits_from_rescue());
}
}
#[test]
fn iterm_and_vscode_readline_no_rescue() {
for brand in [TerminalName::Iterm2, TerminalName::VsCode] {
assert!(
!keyboard_capabilities(brand)
.modifier_delivery
.benefits_from_rescue()
);
}
}
#[test]
fn wezterm_drops_cmd_keeps_opt() {
let c = keyboard_capabilities(TerminalName::WezTerm);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Dropped);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Native);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn alacritty_keeps_cmd_drops_opt() {
let c = keyboard_capabilities(TerminalName::Alacritty);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn rio_keeps_cmd_drops_opt() {
let c = keyboard_capabilities(TerminalName::Rio);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn apple_terminal_cmd_unrecoverable_opt_dropped() {
let c = keyboard_capabilities(TerminalName::AppleTerminal);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Unrecoverable);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
assert!(c.enter_needs_rescue());
}
#[test]
fn unknown_brands_skip_rescue() {
for brand in [
TerminalName::Unknown,
TerminalName::GrokDesktop,
TerminalName::Vte,
TerminalName::JetBrains,
] {
assert!(
!keyboard_capabilities(brand)
.modifier_delivery
.benefits_from_rescue()
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
use std::io::{self, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use ratatui::layout::Rect;
use super::image::{
GraphicsProtocol, KITTY_PLACEMENT_ID, build_overlay_image_escapes_for_protocol,
clear_kitty_image, detect_graphics_protocol, fit_image_to_cells,
};
static NEXT_OWNER_ID: AtomicU64 = AtomicU64::new(1);
thread_local! {
static OWNER: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Ownership {
Static(u64),
Clear,
}
#[derive(Debug)]
pub struct Escapes {
bytes: String,
ownership: Ownership,
}
impl Escapes {
pub fn as_str(&self) -> &str {
&self.bytes
}
pub fn into_string(self) -> String {
self.bytes
}
pub fn commit(self) -> String {
commit(self.ownership);
self.bytes
}
}
#[derive(Debug, Default)]
pub struct PostFlush {
bytes: String,
ownership: Option<Ownership>,
}
impl PostFlush {
pub fn plain(bytes: String) -> Self {
Self {
bytes,
ownership: None,
}
}
pub fn append(&mut self, other: Self) {
self.bytes.push_str(&other.bytes);
if let Some(ownership) = other.ownership {
self.ownership = Some(ownership);
}
}
pub fn append_plain(&mut self, bytes: &str) {
self.bytes.push_str(bytes);
}
pub fn as_str(&self) -> &str {
&self.bytes
}
pub fn write_to(self, writer: &mut impl Write) -> io::Result<()> {
writer.write_all(self.bytes.as_bytes())?;
if let Some(ownership) = self.ownership {
commit(ownership);
}
Ok(())
}
}
impl From<Escapes> for PostFlush {
fn from(escapes: Escapes) -> Self {
Self {
bytes: escapes.bytes,
ownership: Some(escapes.ownership),
}
}
}
pub(crate) fn next_owner_id() -> u64 {
NEXT_OWNER_ID.fetch_add(1, Ordering::Relaxed)
}
pub fn reset_owner() {
OWNER.with(|owner| owner.set(None));
}
fn commit(ownership: Ownership) {
OWNER.with(|owner| {
owner.set(match ownership {
Ownership::Static(id) => Some(id),
Ownership::Clear => None,
});
});
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn static_image_for_protocol(
protocol: GraphicsProtocol,
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
owner_id: u64,
) -> Option<Escapes> {
let retransmit = OWNER.with(|owner| owner.get() != Some(owner_id));
let bytes = build_overlay_image_escapes_for_protocol(
protocol, image_data, cols, rows, cell_x, cell_y, retransmit,
)?;
Some(Escapes {
bytes,
ownership: Ownership::Static(owner_id),
})
}
#[allow(clippy::too_many_arguments)]
pub fn static_image(
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
owner_id: u64,
) -> Option<Escapes> {
static_image_for_protocol(
detect_graphics_protocol(),
image_data,
cols,
rows,
cell_x,
cell_y,
owner_id,
)
}
pub fn volatile_image(
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
) -> Option<Escapes> {
let bytes = build_overlay_image_escapes_for_protocol(
detect_graphics_protocol(),
image_data,
cols,
rows,
cell_x,
cell_y,
true,
)?;
Some(Escapes {
bytes,
ownership: Ownership::Clear,
})
}
pub fn static_centered(
image_data: &[u8],
img_w: u32,
img_h: u32,
overlay_rect: Rect,
owner_id: u64,
) -> Option<Escapes> {
let (cols, rows, x, y) = centered_placement(img_w, img_h, overlay_rect)?;
static_image(image_data, cols, rows, x, y, owner_id)
}
pub fn volatile_centered(
image_data: &[u8],
img_w: u32,
img_h: u32,
overlay_rect: Rect,
) -> Option<Escapes> {
let (cols, rows, x, y) = centered_placement(img_w, img_h, overlay_rect)?;
volatile_image(image_data, cols, rows, x, y)
}
pub fn clear() -> Option<Escapes> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => Some(clear_kitty()),
GraphicsProtocol::ITerm2 | GraphicsProtocol::None => None,
}
}
pub fn clear_kitty() -> Escapes {
Escapes {
bytes: clear_kitty_image(KITTY_PLACEMENT_ID),
ownership: Ownership::Clear,
}
}
fn centered_placement(img_w: u32, img_h: u32, overlay_rect: Rect) -> Option<(u16, u16, u16, u16)> {
let max_cols = overlay_rect.width.saturating_sub(2);
let max_rows = overlay_rect.height.saturating_sub(2);
if max_cols < 4 || max_rows < 2 {
return None;
}
let (cols, rows) = fit_image_to_cells(img_w, img_h, max_cols, max_rows);
let x = overlay_rect.x + 1 + max_cols.saturating_sub(cols) / 2;
let y = overlay_rect.y + 1 + max_rows.saturating_sub(rows) / 2;
Some((cols, rows, x, y))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::image::set_protocol_for_test;
fn png() -> [u8; 8] {
[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
#[test]
fn static_owner_reuses_consecutive_frames_after_commit() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let first = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(first.as_str().contains("a=t"));
let _ = first.commit();
let second = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!second.as_str().contains("a=t"));
}
#[test]
fn discarded_clear_does_not_invalidate_static_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _discarded = clear().unwrap();
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn discarded_static_escape_does_not_replace_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _discarded = static_image(&png(), 20, 10, 0, 0, 12).unwrap();
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn failed_post_flush_write_does_not_commit_transition() {
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("injected write failure"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let clear = PostFlush::from(clear().unwrap());
assert!(clear.write_to(&mut FailingWriter).is_err());
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn committed_clear_and_volatile_frame_invalidate_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _ = clear().unwrap().commit();
assert!(
static_image(&png(), 20, 10, 0, 0, 11)
.unwrap()
.as_str()
.contains("a=t")
);
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _ = volatile_image(&png(), 20, 10, 0, 0).unwrap().commit();
assert!(
static_image(&png(), 20, 10, 0, 0, 11)
.unwrap()
.as_str()
.contains("a=t")
);
}
}
@@ -0,0 +1,159 @@
//! Shared startup terminal-probe primitive: write a query, and (OSC 11
//! only) raw-fd poll/read stdin until a terminator or deadline.
//! XTVERSION uses only `write_query`;
//! its reply is handled by the event loop's response filter.
//!
//! Safety invariants (timed-read path):
//! - Startup-only: must run before crossterm's `EventStream` exists (both
//! compete for stdin).
//! - Keystrokes typed inside the read window are consumed and dropped — no
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
use std::io::Write;
use std::time::Duration;
/// Bounds the reply buffer against terminals that stream without a terminator.
#[cfg(unix)]
pub(crate) const MAX_PROBE_RESPONSE: usize = 256;
/// Hard cap on post-deadline consumption of an in-flight reply.
#[cfg(unix)]
const LATE_REPLY_GRACE: Duration = Duration::from_millis(100);
/// Per-byte quiet window during the grace period.
#[cfg(unix)]
const LATE_REPLY_QUIET_MS: i32 = 25;
/// Write a probe query via the shared stderr lock; `false` if the TUI fd is
/// not a TTY or the write fails.
pub(crate) fn write_query(query: &[u8]) -> bool {
use std::io::IsTerminal;
let write_result: std::io::Result<()> = kigi_shared::stderr::with_locked_stderr(|stderr| {
// fd 2 is /dev/null-redirected; the TTY check must run on the
// dup'd render fd inside the lock, not on std::io::stderr().
if !stderr.is_terminal() {
return Err(std::io::Error::other("TUI output is not a TTY"));
}
stderr.write_all(query)?;
stderr.flush()
});
write_result.is_ok()
}
/// Read stdin until `is_terminated`, the size cap, or the deadline.
///
/// Returns `Some(buf)` whenever bytes were consumed (even partial, so a
/// half-read reply is never left for the EventStream); `None` when nothing
/// arrived or stdin errored before any byte.
#[cfg(unix)]
pub(crate) fn read_tty_reply(
timeout: Duration,
mut is_terminated: impl FnMut(&[u8], u8) -> bool,
) -> Option<Vec<u8>> {
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let start = std::time::Instant::now();
let mut buf: Vec<u8> = Vec::with_capacity(64);
loop {
let Some(remaining) = timeout.checked_sub(start.elapsed()) else {
return finish_after_deadline(fd, buf, is_terminated);
};
let remaining_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
match poll_read_byte(fd, remaining_ms) {
PollRead::Byte(byte) => {
buf.push(byte);
if buf.len() >= MAX_PROBE_RESPONSE || is_terminated(&buf, byte) {
return Some(buf);
}
}
// Re-entry recomputes the deadline, so EINTR cannot extend it.
PollRead::Interrupted => continue,
PollRead::Timeout => return finish_after_deadline(fd, buf, is_terminated),
PollRead::Error => return if buf.is_empty() { None } else { Some(buf) },
}
}
}
/// Deadline expiry: an in-flight reply (ESC byte seen — replies are
/// DCS/CSI/OSC, plain keystrokes aren't) is consumed until quiet so its
/// tail can't reach the EventStream as typed garbage; otherwise return
/// immediately to avoid eating keystrokes at a silent terminal.
#[cfg(unix)]
fn finish_after_deadline(
fd: i32,
mut buf: Vec<u8>,
mut is_terminated: impl FnMut(&[u8], u8) -> bool,
) -> Option<Vec<u8>> {
if buf.is_empty() {
return None;
}
if !buf.contains(&0x1b) {
return Some(buf);
}
let grace_start = std::time::Instant::now();
while grace_start.elapsed() < LATE_REPLY_GRACE {
match poll_read_byte(fd, LATE_REPLY_QUIET_MS) {
PollRead::Byte(byte) => {
buf.push(byte);
if buf.len() >= MAX_PROBE_RESPONSE || is_terminated(&buf, byte) {
break;
}
}
PollRead::Interrupted => continue,
PollRead::Timeout | PollRead::Error => break,
}
}
Some(buf)
}
#[cfg(unix)]
enum PollRead {
Byte(u8),
Interrupted,
Timeout,
Error,
}
/// One EINTR-retrying poll-then-read step for a single byte.
#[cfg(unix)]
fn poll_read_byte(fd: i32, timeout_ms: i32) -> PollRead {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
// SAFETY: pfd is a valid pollfd struct with a valid fd.
let ret = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
if ret == 0 {
return PollRead::Timeout;
}
if ret < 0 {
return if last_errno_is_eintr() {
PollRead::Interrupted
} else {
PollRead::Error
};
}
loop {
let mut byte = [0u8; 1];
// SAFETY: byte is a valid buffer of length 1.
let n = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
if n == 1 {
return PollRead::Byte(byte[0]);
}
if n < 0 && last_errno_is_eintr() {
continue;
}
return PollRead::Error;
}
}
#[cfg(unix)]
fn last_errno_is_eintr() -> bool {
std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
//! Runtime XTVERSION probe (`CSI > 0 q` → `DCS > | text ST`), run when
//! env-based brand detection yields Unknown (SSH, plain xterm) or a
//! headfully-validated allowlisted brand (see [`gate_allows_probe`]).
//!
//! Fire-and-forget, parser-integrated model (as in helix and similar TUIs):
//! the query is written once at startup with no timed read; the reply is
//! recognized and swallowed by the event loop's `XtversionFilter` whenever
//! it arrives.
//!
//! Safety invariants:
//! - Query write must happen after `enable_raw_mode()` and before the
//! `EventStream` filter is constructed.
//! - Accepted residuals: SSH *from* JediTerm still probes (its env marker
//! doesn't cross SSH) and leaks the query there; a reply whose first
//! event arrives only after the filter's 5s arm window types as
//! Alt+Shift+P + literal text; on a silent terminal with a fully idle
//! session the `OnceLock` stays unset (`record_no_reply` only runs from
//! the filter, which only runs on input) — `detected()` is None either
//! way, so both consumers are unaffected.
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
/// Startup probe outcome.
#[derive(Debug)]
enum ProbeResult {
Skipped,
NoReply,
Identified(String),
}
/// Unset while the query is in flight (or never sent).
static XTVERSION: OnceLock<ProbeResult> = OnceLock::new();
/// True once the query bytes were written to the terminal.
static QUERY_SENT: AtomicBool = AtomicBool::new(false);
/// XTVERSION query alone — no DA1 sentinel: nothing waits on reply
/// ordering here, and a stale unsolicited DA1 reply could mis-answer a
/// future crossterm DA1-waiting probe.
#[cfg(unix)]
const QUERY: &[u8] = b"\x1b[>0q";
/// Returns the terminal's self-reported name/version, if the terminal
/// answered (e.g. `"kitty 0.35.2"`, `"foot(1.22.0)"`).
pub fn detected() -> Option<&'static str> {
match XTVERSION.get() {
Some(ProbeResult::Identified(v)) => Some(v),
_ => None,
}
}
/// True when the query was sent and no reply has been recorded yet — the
/// event loop arms its response filter on this.
pub fn reply_pending() -> bool {
QUERY_SENT.load(Ordering::Relaxed) && XTVERSION.get().is_none()
}
/// Record the DCS payload recognized by the event-loop filter.
pub fn record_reply(payload: &str) {
let result = match sanitize_payload(payload) {
Some(v) => ProbeResult::Identified(v),
None => ProbeResult::NoReply,
};
tracing::info!(?result, "XTVERSION probe");
let _ = XTVERSION.set(result);
}
/// Record that the filter disarmed without seeing a reply. Only invoked
/// from the filter on input, so a fully idle session can leave the
/// `OnceLock` unset (benign — see module doc).
pub fn record_no_reply() {
if XTVERSION.set(ProbeResult::NoReply).is_ok() {
tracing::info!("XTVERSION probe: no reply");
}
}
/// Send the XTVERSION query once at startup (fire-and-forget); no-ops when
/// the gate rejects the brand/multiplexer or stdin is not a TTY.
pub fn probe_at_startup() {
use std::io::IsTerminal;
if XTVERSION.get().is_some() || QUERY_SENT.load(Ordering::Relaxed) {
return;
}
let ctx = super::terminal_context();
if !gate_allows_probe(ctx) || !std::io::stdin().is_terminal() {
let _ = XTVERSION.set(ProbeResult::Skipped);
return;
}
send_query();
}
/// Crush-style brand allowlist: Unknown plus brands headfully validated as
/// clean XTVERSION responders (version fidelity is the payoff there).
/// CSI-intercepting multiplexers skip — the innermost layer answers as
/// itself, which the `multiplexer` field already records. Transparent muxes
/// (e.g. cmux) need no special case.
fn gate_allows_probe(ctx: &super::TerminalContext) -> bool {
use super::TerminalName::*;
matches!(
ctx.brand,
Unknown | Kitty | WezTerm | Ghostty | Iterm2 | Rio
) && !ctx.multiplexer.intercepts_csi_queries()
}
#[cfg(unix)]
fn send_query() {
if super::probe::write_query(QUERY) {
QUERY_SENT.store(true, Ordering::Relaxed);
} else {
// Brand-Unknown TTY whose query can't reach the terminal is a
// feedback-triage signal worth tracing.
tracing::debug!("XTVERSION probe skipped: query write failed or output is not a TTY");
let _ = XTVERSION.set(ProbeResult::Skipped);
}
}
#[cfg(not(unix))]
fn send_query() {
// ConPTY does not implement XTVERSION.
let _ = XTVERSION.set(ProbeResult::Skipped);
}
/// Strip controls and trim; `None` for an empty payload.
fn sanitize_payload(payload: &str) -> Option<String> {
let cleaned: String = payload.chars().filter(|c| !c.is_control()).collect();
let cleaned = cleaned.trim().to_owned();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_plain_payload() {
assert_eq!(
sanitize_payload("kitty 0.35.2").as_deref(),
Some("kitty 0.35.2")
);
assert_eq!(
sanitize_payload("XTerm(388)").as_deref(),
Some("XTerm(388)")
);
}
#[test]
fn sanitize_strips_controls_and_whitespace() {
assert_eq!(
sanitize_payload(" We\x01zTerm 2.0 ").as_deref(),
Some("WezTerm 2.0")
);
}
#[test]
fn sanitize_empty_is_none() {
assert_eq!(sanitize_payload(""), None);
assert_eq!(sanitize_payload(" \x07 "), None);
}
#[test]
fn gate_allows_unknown_and_allowlisted_brands() {
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
let ctx = |brand, multiplexer| TerminalContext {
brand,
multiplexer,
..Default::default()
};
for brand in [
TerminalName::Unknown,
TerminalName::Kitty,
TerminalName::WezTerm,
TerminalName::Ghostty,
TerminalName::Iterm2,
TerminalName::Rio,
] {
assert!(
gate_allows_probe(&ctx(brand, MultiplexerKind::Undetected)),
"{brand:?} should be probed"
);
// Transparent mux (cmux) does not intercept CSI; probe still runs.
assert!(
gate_allows_probe(&ctx(brand, MultiplexerKind::Cmux)),
"{brand:?} under cmux should still be probed"
);
// CSI-intercepting multiplexers override the brand allowlist.
assert!(
!gate_allows_probe(&ctx(brand, MultiplexerKind::Tmux)),
"{brand:?} under tmux should be skipped"
);
}
// JediTerm renders the query as garbage and must never be probed.
assert!(!gate_allows_probe(&ctx(
TerminalName::JetBrains,
MultiplexerKind::Undetected
)));
}
// Sets the process-global OnceLock — safe under nextest's
// process-per-test isolation.
#[test]
fn diagnostics_snapshot_includes_recorded_reply() {
record_reply("PtyHarnessTerm 9.9");
let t = crate::terminal::terminal_context().diagnostics_snapshot();
assert_eq!(t.xtversion, "PtyHarnessTerm 9.9");
}
}