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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user