The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
2231 lines
84 KiB
Rust
2231 lines
84 KiB
Rust
//! Route-aware terminal diagnostics engine.
|
|
//!
|
|
//! Classifies startup warnings by category using [`TerminalContext`] and an
|
|
//! injectable [`TmuxOptionQuery`] trait. Warnings are data-only; the engine
|
|
//! returns `Vec<TerminalWarning>` for downstream banner rendering.
|
|
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
use crate::notifications::NotificationCondition;
|
|
use crate::notifications::protocol::NotificationProtocol;
|
|
use crate::terminal::{ByobuBackend, MultiplexerKind, TerminalContext, TerminalName};
|
|
use crate::theme::ThemeKind;
|
|
use crate::theme::color_support::ColorLevel;
|
|
|
|
/// Broad classification of a startup warning.
|
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
pub enum WarningCategory {
|
|
/// OSC 52 clipboard passthrough is misconfigured in tmux.
|
|
Clipboard,
|
|
/// DCS passthrough is disabled (nested clipboard path).
|
|
DcsPassthrough,
|
|
/// tmux control-mode degrades the fullscreen experience.
|
|
ControlMode,
|
|
/// The session is running inside Byobu backed by GNU screen — best-effort
|
|
/// support only.
|
|
ByobuScreen,
|
|
/// The terminal emulator (Apple Terminal.app) does not support OSC 52
|
|
/// clipboard escape sequences.
|
|
UnsupportedTerminal,
|
|
/// tmux 3.3+ has `extended-keys` set to `off`, so kitty CSI-u responses
|
|
/// would be stripped before they reach the pager.
|
|
TmuxExtendedKeysOff,
|
|
/// The terminal is unrecognised and the notification protocol fell back to
|
|
/// BEL (audible bell only).
|
|
NotificationProtocolFallback,
|
|
/// The terminal does not reliably support CSI focus-tracking events, so
|
|
/// `condition = "unfocused"` will never fire.
|
|
FocusTrackingUnavailable,
|
|
/// WezTerm with the Kitty keyboard protocol inactive (its
|
|
/// `enable_kitty_keyboard` option defaults to `false`), so Shift+Enter
|
|
/// is byte-identical to Enter and can't insert newlines — and WezTerm's
|
|
/// default Alt+Enter binding (ToggleFullScreen) eats the fallback chord.
|
|
WezTermKittyKeyboardOff,
|
|
/// Wayland session whose compositor lacks the data-control clipboard
|
|
/// protocol (GNOME ≤ 47), so every native copy rides a focus-dependent
|
|
/// path (arboard via the XWayland selection bridge, `wl-copy`'s
|
|
/// no-data-control fallback) and fails if the terminal loses focus
|
|
/// mid-copy.
|
|
WaylandNoDataControl,
|
|
/// Below truecolor: truecolor themes hidden. `/terminal-setup` only.
|
|
LimitedColorSupport,
|
|
SandboxProfileConflict,
|
|
}
|
|
|
|
/// A structured startup warning carrying category, human-readable description,
|
|
/// and optional fix guidance.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct TerminalWarning {
|
|
/// The warning category for downstream grouping and filtering.
|
|
pub category: WarningCategory,
|
|
/// A short human-readable description of the problem.
|
|
pub message: String,
|
|
/// Optional fix instruction (e.g. a tmux config line to add).
|
|
pub fix: Option<String>,
|
|
/// The config file path where the fix should be applied, when applicable.
|
|
pub config_path: Option<String>,
|
|
/// Optional note about trade-offs or side effects of the fix.
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
impl TerminalWarning {
|
|
/// Create a new warning with all fields.
|
|
fn new(
|
|
category: WarningCategory,
|
|
message: &str,
|
|
fix: Option<&str>,
|
|
config_path: Option<&str>,
|
|
) -> Self {
|
|
Self {
|
|
category,
|
|
message: message.to_string(),
|
|
fix: fix.map(|s| s.to_string()),
|
|
config_path: config_path.map(|s| s.to_string()),
|
|
note: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Summarize a list of terminal warnings into a single [`StartupWarning`]
|
|
/// for the welcome screen. Returns `None` if there are no warnings or if
|
|
/// none of the warnings are in the allow-list of safe-to-surface categories.
|
|
///
|
|
/// The welcome screen doesn't need to know what's wrong -- only that
|
|
/// something is wrong and where to go for details.
|
|
pub fn summarize_warnings(warnings: &[TerminalWarning]) -> Option<crate::startup::StartupWarning> {
|
|
// Only surface warnings over SSH — locally these tmux misconfigurations
|
|
// don't actually break clipboard, so showing the banner would be noise.
|
|
let is_ssh = kigi_shell::util::clipboard::is_remote_session();
|
|
summarize_warnings_inner(warnings, is_ssh)
|
|
}
|
|
|
|
/// Inner implementation of [`summarize_warnings`] that accepts the SSH flag
|
|
/// as a parameter so tests can exercise both paths without manipulating
|
|
/// environment variables.
|
|
fn summarize_warnings_inner(
|
|
warnings: &[TerminalWarning],
|
|
is_ssh: bool,
|
|
) -> Option<crate::startup::StartupWarning> {
|
|
if !is_ssh {
|
|
return None;
|
|
}
|
|
// Allow-list of categories where detection is a direct tmux subprocess
|
|
// query that only triggers on an explicit non-good value, and the fix is
|
|
// a single config line. Other categories stay suppressed until their
|
|
// false-positive rate is characterized.
|
|
warnings.iter().find(|w| {
|
|
matches!(
|
|
w.category,
|
|
WarningCategory::TmuxExtendedKeysOff | WarningCategory::DcsPassthrough
|
|
)
|
|
})?;
|
|
Some(crate::startup::StartupWarning {
|
|
severity: crate::startup::WarningSeverity::Warning,
|
|
message: "Clipboard may be unreachable.".to_string(),
|
|
action: Some("See /terminal-setup for potential fixes.".to_string()),
|
|
})
|
|
}
|
|
|
|
/// Abstraction over tmux option queries so diagnostic logic can be tested
|
|
/// without a live tmux server.
|
|
///
|
|
/// The two methods mirror the subprocess helpers [`tmux_show_option`] and
|
|
/// [`tmux_option_exists`] but can be replaced with deterministic fixtures in
|
|
/// tests.
|
|
pub trait TmuxOptionQuery {
|
|
/// Return the value of a global tmux option, or `None` if the query fails
|
|
/// or the value is empty.
|
|
fn show_option(&self, option: &str) -> Option<String>;
|
|
|
|
/// Return `true` if the tmux server recognises `option` as a valid global
|
|
/// option name.
|
|
fn option_exists(&self, option: &str) -> bool;
|
|
}
|
|
|
|
/// The real, subprocess-backed implementation used at runtime.
|
|
pub struct LiveTmuxQuery;
|
|
|
|
impl TmuxOptionQuery for LiveTmuxQuery {
|
|
fn show_option(&self, option: &str) -> Option<String> {
|
|
tmux_show_option(option)
|
|
}
|
|
|
|
fn option_exists(&self, option: &str) -> bool {
|
|
tmux_option_exists(option)
|
|
}
|
|
}
|
|
|
|
/// Collect all applicable startup warnings for the current terminal context.
|
|
///
|
|
/// This is the primary entry point for the diagnostics engine. It returns
|
|
/// structured warnings as data — no stderr output, no sleep, no side effects.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ctx` — The resolved terminal context (multiplexer, Byobu state, etc.).
|
|
/// * `query` — A tmux option query implementation (live or fake for tests).
|
|
/// * `is_control_mode` — Whether the tmux session is in control mode.
|
|
/// * `fullscreen_active` — Whether fullscreen (alt-screen) is effectively
|
|
/// active. Used to tailor the control-mode warning message.
|
|
pub fn collect_startup_warnings(
|
|
ctx: &TerminalContext,
|
|
query: &dyn TmuxOptionQuery,
|
|
is_control_mode: bool,
|
|
fullscreen_active: bool,
|
|
) -> Vec<TerminalWarning> {
|
|
let mut warnings = Vec::new();
|
|
|
|
// Apple Terminal.app does not support OSC 52. Over SSH, this means
|
|
// clipboard writes can never reach the user's local machine.
|
|
if ctx.brand == TerminalName::AppleTerminal && kigi_shell::util::clipboard::is_remote_session()
|
|
{
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::UnsupportedTerminal,
|
|
"macOS Terminal does not support clipboard escape sequences (OSC 52) \
|
|
-- copy over SSH will not work.",
|
|
None,
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Byobu-on-screen: best-effort warning, no further tmux-specific checks.
|
|
if ctx.byobu == Some(ByobuBackend::Screen) {
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::ByobuScreen,
|
|
"Byobu with GNU screen backend -- clipboard and display support is best-effort",
|
|
None,
|
|
None,
|
|
));
|
|
return warnings;
|
|
}
|
|
|
|
// tmux control-mode warning — the message reflects the effective
|
|
// fullscreen state so callers that force fullscreen in control mode
|
|
// (e.g. `alt_screen = "always"`) see an accurate warning rather than a
|
|
// blanket "inline mode" claim.
|
|
if ctx.is_tmux_backed() && is_control_mode {
|
|
let message = if fullscreen_active {
|
|
"tmux control mode detected -- fullscreen may be unreliable in control mode"
|
|
} else {
|
|
"tmux control mode detected -- running in degraded inline mode"
|
|
};
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::ControlMode,
|
|
message,
|
|
None,
|
|
None,
|
|
));
|
|
}
|
|
|
|
// Resolve tmux config path once for all tmux-related warnings below.
|
|
let config_path = ctx.tmux_config_path();
|
|
|
|
// tmux-backed clipboard and DCS passthrough checks.
|
|
if ctx.is_tmux_backed() {
|
|
let clipboard_warnings = diagnose_clipboard_with_query(query, &config_path);
|
|
warnings.extend(clipboard_warnings);
|
|
}
|
|
|
|
if ctx.kitty_skip_reason() == Some("tmux_extended_keys_off") {
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::TmuxExtendedKeysOff,
|
|
"tmux extended-keys is off -- modifier key combinations may not reach the pager",
|
|
Some("set -g extended-keys on"),
|
|
Some(&config_path),
|
|
);
|
|
// Existing tmux sessions cache the option; without an explicit
|
|
// reload the user will edit the config, see no change, and
|
|
// conclude the fix is broken.
|
|
warning.note = Some(format!(
|
|
"Then reload tmux: `tmux source-file {config_path}` (or detach and reattach)."
|
|
));
|
|
warnings.push(warning);
|
|
}
|
|
|
|
warnings
|
|
}
|
|
|
|
/// Warn when WezTerm is running without the Kitty keyboard protocol.
|
|
///
|
|
/// WezTerm ships `enable_kitty_keyboard = false` by default, so the pager's
|
|
/// runtime probe fails and no enhancement flags are pushed. Without KKP,
|
|
/// Shift+Enter arrives as a bare `CR` (submits instead of inserting a
|
|
/// newline), and WezTerm's default Alt+Enter binding (ToggleFullScreen)
|
|
/// swallows the usual fallback chord before it reaches the PTY.
|
|
///
|
|
/// WezTerm is recognized two ways:
|
|
/// - env detection (`ctx.brand`) for local sessions, and
|
|
/// - the async XTVERSION self-report (`xtversion_payload`) for SSH
|
|
/// sessions, where `TERM_PROGRAM` isn't forwarded and the brand falls
|
|
/// back to `Unknown`. The reply arrives through the event loop after
|
|
/// startup, so this path lights up for `/terminal-setup` (and any
|
|
/// warning pass re-run after the reply landed) rather than the very
|
|
/// first startup banner.
|
|
///
|
|
/// `kitty_flags_pushed` is the runtime negotiation outcome from
|
|
/// `init_terminal` (passed in so this stays a pure, testable function).
|
|
/// Returns `None` when KKP is active, when the terminal isn't WezTerm, or
|
|
/// when a non-WezTerm [`TerminalContext::kitty_skip_reason`] applies (e.g.
|
|
/// tmux) — in that case the wezterm.lua fix alone wouldn't help and other
|
|
/// warnings cover it.
|
|
pub fn wezterm_kitty_keyboard_warning(
|
|
ctx: &TerminalContext,
|
|
kitty_flags_pushed: bool,
|
|
xtversion_payload: Option<&str>,
|
|
) -> Option<TerminalWarning> {
|
|
let is_wezterm_by_env = ctx.brand == TerminalName::WezTerm;
|
|
// SSH shape: brand Unknown, no multiplexer, over SSH, but the terminal
|
|
// identified itself as WezTerm via XTVERSION. (Under tmux the reply
|
|
// would describe tmux itself, and the brand gate below rejects it.)
|
|
//
|
|
// The `is_ssh` gate matters: a *local* WezTerm with a stripped
|
|
// `TERM_PROGRAM` (brand falls back to Unknown) can still answer
|
|
// XTVERSION with "WezTerm". Without this gate it would receive the
|
|
// "over SSH" copy below, which is both wrong (it's local) and would
|
|
// drop the actionable `wezterm.lua` fix. We only treat XTVERSION as
|
|
// the WezTerm signal when env detection genuinely can't apply, i.e.
|
|
// over SSH where `TERM_PROGRAM` isn't forwarded.
|
|
let is_wezterm_by_xtversion = ctx.brand == TerminalName::Unknown
|
|
&& ctx.multiplexer == MultiplexerKind::Undetected
|
|
&& ctx.is_ssh
|
|
&& xtversion_payload.is_some_and(|v| v.trim_start().starts_with("WezTerm"));
|
|
if !(is_wezterm_by_env || is_wezterm_by_xtversion) || kitty_flags_pushed {
|
|
return None;
|
|
}
|
|
// For env-detected WezTerm, a skip reason (tmux etc.) means KKP was
|
|
// never even probed and the wezterm.lua fix alone wouldn't help.
|
|
if is_wezterm_by_env && ctx.kitty_skip_reason().is_some() {
|
|
return None;
|
|
}
|
|
if is_wezterm_by_xtversion {
|
|
// Over SSH the pager skips KKP for Unknown brands entirely (no
|
|
// positive evidence policy — see `kitty_skip_reason`), so the
|
|
// wezterm.lua change cannot clear this warning for SSH sessions.
|
|
// Be honest: lead with the workaround that does work.
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::WezTermKittyKeyboardOff,
|
|
"WezTerm over SSH: Shift+Enter can't insert newlines",
|
|
None,
|
|
None,
|
|
);
|
|
warning.note = Some(
|
|
"Type `\\` then Enter to insert a newline. The pager doesn't negotiate the \
|
|
kitty keyboard protocol over SSH yet; `enable_kitty_keyboard = true` in \
|
|
wezterm.lua fixes local WezTerm sessions only."
|
|
.to_string(),
|
|
);
|
|
return Some(warning);
|
|
}
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::WezTermKittyKeyboardOff,
|
|
"WezTerm: Shift+Enter can't insert newlines (kitty keyboard protocol is off)",
|
|
Some("config.enable_kitty_keyboard = true"),
|
|
Some("~/.config/wezterm/wezterm.lua"),
|
|
);
|
|
warning.note = Some(
|
|
"Restart WezTerm after the change. Until then, type `\\` then Enter to insert \
|
|
a newline."
|
|
.to_string(),
|
|
);
|
|
Some(warning)
|
|
}
|
|
|
|
pub fn sandbox_profile_conflict_warning(workspace: &Path) -> Option<TerminalWarning> {
|
|
sandbox_profile_conflict_warning_from(kigi_sandbox::sandbox_profile_conflicts(workspace))
|
|
}
|
|
|
|
fn sandbox_profile_conflict_warning_from(conflicts: Vec<String>) -> Option<TerminalWarning> {
|
|
if conflicts.is_empty() {
|
|
return None;
|
|
}
|
|
let profiles = conflicts
|
|
.iter()
|
|
.map(|name| format!("'{name}'"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Some(TerminalWarning {
|
|
category: WarningCategory::SandboxProfileConflict,
|
|
message: format!(
|
|
"Your project sandbox profile conflicts with user config.\nProfile: {profiles}\nProject config: .kigi/sandbox.toml\nUser config: ~/.kigi/sandbox.toml"
|
|
),
|
|
fix: Some("Using the user profile instead.".to_string()),
|
|
config_path: None,
|
|
note: None,
|
|
})
|
|
}
|
|
|
|
/// Assemble the welcome-screen startup warning list.
|
|
///
|
|
/// The WezTerm kitty-keyboard warning (when present) goes **first**: the
|
|
/// welcome screen renders only the first entry, and a broken-local-input
|
|
/// warning outranks the SSH clipboard advisories from
|
|
/// [`summarize_warnings`]. The Wayland no-data-control warning follows the
|
|
/// same bypass (surfaced locally — [`summarize_warnings`] is SSH-gated — but
|
|
/// after WezTerm: broken input outranks focus-dependent copies). Keeping the
|
|
/// banner copy here (instead of at the call site) ties it to the warnings so
|
|
/// the surfaces can't drift.
|
|
pub fn assemble_startup_warnings(
|
|
wezterm_warning: Option<&TerminalWarning>,
|
|
wayland_clipboard_warning: Option<&TerminalWarning>,
|
|
sandbox_profile_warning: Option<&TerminalWarning>,
|
|
mut summarized: Vec<crate::startup::StartupWarning>,
|
|
) -> Vec<crate::startup::StartupWarning> {
|
|
if let Some(w) = sandbox_profile_warning {
|
|
summarized.insert(
|
|
0,
|
|
crate::startup::StartupWarning {
|
|
severity: crate::startup::WarningSeverity::Warning,
|
|
message: w.message.clone(),
|
|
action: w.fix.clone(),
|
|
},
|
|
);
|
|
}
|
|
if wayland_clipboard_warning.is_some() {
|
|
summarized.insert(
|
|
0,
|
|
crate::startup::StartupWarning {
|
|
severity: crate::startup::WarningSeverity::Warning,
|
|
message: "Copies need this terminal to stay focused.".to_string(),
|
|
action: Some("See /terminal-setup for details.".to_string()),
|
|
},
|
|
);
|
|
}
|
|
if wezterm_warning.is_some() {
|
|
summarized.insert(
|
|
0,
|
|
crate::startup::StartupWarning {
|
|
severity: crate::startup::WarningSeverity::Warning,
|
|
message: "Shift+Enter newlines need a WezTerm config change.".to_string(),
|
|
action: Some("See /terminal-setup for the fix.".to_string()),
|
|
},
|
|
);
|
|
}
|
|
summarized
|
|
}
|
|
|
|
/// Returns `true` if the terminal brand is known to support CSI focus-tracking
|
|
/// events (`\x1b[?1004h` / focus-in / focus-out sequences).
|
|
fn supports_focus_tracking(brand: TerminalName) -> bool {
|
|
brand != TerminalName::AppleTerminal && !brand.is_capability_unclassified()
|
|
}
|
|
|
|
/// Collect notification-specific startup warnings.
|
|
///
|
|
/// These complement the general terminal warnings from
|
|
/// [`collect_startup_warnings`] and depend on the resolved notification
|
|
/// protocol and condition.
|
|
pub fn collect_notification_warnings(
|
|
ctx: &TerminalContext,
|
|
protocol: NotificationProtocol,
|
|
condition: NotificationCondition,
|
|
query: &dyn TmuxOptionQuery,
|
|
) -> Vec<TerminalWarning> {
|
|
let mut warnings = Vec::new();
|
|
|
|
// Protocol fallback: BEL selected for an unknown terminal in auto mode.
|
|
if protocol == NotificationProtocol::Bel && ctx.brand == TerminalName::Unknown {
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::NotificationProtocolFallback,
|
|
"notification protocol fell back to BEL -- terminal not recognized",
|
|
None,
|
|
None,
|
|
));
|
|
}
|
|
|
|
// tmux + OSC protocol: allow-passthrough must be on or OSC notification
|
|
// sequences wrapped in DCS passthrough will be silently dropped.
|
|
if ctx.is_tmux_backed()
|
|
&& matches!(
|
|
protocol,
|
|
NotificationProtocol::Osc9 | NotificationProtocol::Osc99 | NotificationProtocol::Osc777
|
|
)
|
|
&& query.option_exists("allow-passthrough")
|
|
&& let Some(val) = query.show_option("allow-passthrough")
|
|
&& !matches!(val.as_str(), "on" | "all")
|
|
{
|
|
let config_path = ctx.tmux_config_path();
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::DcsPassthrough,
|
|
"tmux allow-passthrough is off -- OSC notifications will not reach the terminal",
|
|
Some("set -g allow-passthrough on"),
|
|
Some(&config_path),
|
|
));
|
|
}
|
|
|
|
// Focus tracking: if the terminal doesn't support it and the condition
|
|
// is "unfocused", notifications will never fire because the pager will
|
|
// always think the window is focused.
|
|
if condition == NotificationCondition::Unfocused && !supports_focus_tracking(ctx.brand) {
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::FocusTrackingUnavailable,
|
|
"focus tracking may not be supported -- unfocused notifications may not fire",
|
|
Some("set condition = \"always\" in [ui.notifications]"),
|
|
None,
|
|
));
|
|
}
|
|
|
|
warnings
|
|
}
|
|
|
|
/// Query tmux clipboard settings via the given [`TmuxOptionQuery`] and return
|
|
/// structured clipboard/DCS warnings.
|
|
fn diagnose_clipboard_with_query(
|
|
query: &dyn TmuxOptionQuery,
|
|
config_path: &str,
|
|
) -> Vec<TerminalWarning> {
|
|
let set_clipboard = query.show_option("set-clipboard");
|
|
let passthrough_exists = query.option_exists("allow-passthrough");
|
|
let allow_passthrough = if passthrough_exists {
|
|
query.show_option("allow-passthrough")
|
|
} else {
|
|
None
|
|
};
|
|
|
|
diagnose_clipboard_from_values(
|
|
set_clipboard.as_deref(),
|
|
passthrough_exists,
|
|
allow_passthrough.as_deref(),
|
|
config_path,
|
|
)
|
|
}
|
|
|
|
/// Pure clipboard diagnostic logic — determines which tmux clipboard settings
|
|
/// are misconfigured and returns structured warnings.
|
|
///
|
|
/// **Query-failure semantics:** `None` for `set_clipboard` or
|
|
/// `allow_passthrough` means the tmux query could not obtain a value (e.g. the
|
|
/// tmux server is unreachable or the option is unset). This is *not* proof
|
|
/// that the setting is disabled, so `None` does **not** trigger a
|
|
/// misconfiguration warning. Only an explicit non-good value (e.g. `"off"`)
|
|
/// produces a warning with remediation guidance.
|
|
///
|
|
/// - `set_clipboard`: value of `set-clipboard` (`None` = query unavailable).
|
|
/// - `passthrough_exists`: whether the tmux server knows the `allow-passthrough`
|
|
/// option (introduced in tmux 3.3; older versions don't have it).
|
|
/// - `allow_passthrough`: value of `allow-passthrough` when it exists.
|
|
/// - `config_path`: the tmux config file path for fix guidance.
|
|
pub fn diagnose_clipboard_from_values(
|
|
set_clipboard: Option<&str>,
|
|
passthrough_exists: bool,
|
|
allow_passthrough: Option<&str>,
|
|
config_path: &str,
|
|
) -> Vec<TerminalWarning> {
|
|
let mut warnings = Vec::new();
|
|
|
|
// set-clipboard: required for OSC 52 passthrough so the pager can write to
|
|
// the user's local clipboard.
|
|
//
|
|
// `None` = query failed or value unavailable → do not claim it is disabled.
|
|
// Only warn when the query returned an explicit non-good value.
|
|
if let Some(val) = set_clipboard
|
|
&& !matches!(val, "on" | "external")
|
|
{
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::Clipboard,
|
|
"OSC 52 clipboard passthrough is disabled",
|
|
Some("set -g set-clipboard on"),
|
|
Some(config_path),
|
|
));
|
|
}
|
|
|
|
// allow-passthrough: needed for DCS passthrough of OSC 52 in nested tmux.
|
|
// This option was introduced in tmux 3.3. Before 3.3, DCS passthrough
|
|
// worked unconditionally, so we only warn when the option actually exists.
|
|
//
|
|
// Same query-failure semantics: `None` does not produce a warning.
|
|
if passthrough_exists
|
|
&& let Some(val) = allow_passthrough
|
|
&& !matches!(val, "on" | "all")
|
|
{
|
|
warnings.push(TerminalWarning::new(
|
|
WarningCategory::DcsPassthrough,
|
|
"DCS passthrough is disabled (needed for nested clipboard)",
|
|
Some("set -g allow-passthrough on"),
|
|
Some(config_path),
|
|
));
|
|
}
|
|
|
|
warnings
|
|
}
|
|
|
|
/// Pure Wayland clipboard diagnostic — flags the focus-dependent copy shape.
|
|
///
|
|
/// Warns only when the session is Wayland AND the compositor lacks the
|
|
/// data-control protocol: without it every native write (arboard via the
|
|
/// XWayland bridge, `wl-copy`'s fallback) needs the terminal focused until the
|
|
/// write completes, so alt-tabbing mid-copy loses the copy. When `wl-copy` is
|
|
/// also missing, the fix suggests installing wl-clipboard — a partial
|
|
/// mitigation (its verified write is the most reliable non-data-control
|
|
/// route). No warning when data-control is present (copies are focus-free) or
|
|
/// off Wayland.
|
|
pub fn diagnose_wayland_data_control(
|
|
is_wayland: bool,
|
|
data_control: bool,
|
|
wl_copy_available: bool,
|
|
) -> Option<TerminalWarning> {
|
|
if !is_wayland || data_control {
|
|
return None;
|
|
}
|
|
let fix = (!wl_copy_available)
|
|
.then_some("sudo apt install wl-clipboard (or your distro's equivalent)");
|
|
Some(TerminalWarning::new(
|
|
WarningCategory::WaylandNoDataControl,
|
|
"Wayland compositor without the data-control clipboard protocol -- \
|
|
keep the terminal focused while copying until the copy toast confirms",
|
|
fix,
|
|
None,
|
|
))
|
|
}
|
|
|
|
/// Live-environment wrapper for [`diagnose_wayland_data_control`], called from
|
|
/// the two warning surfaces (startup event loop and `/terminal-setup`) — NOT
|
|
/// from `collect_startup_warnings`, whose integration tests stay hermetic on
|
|
/// Wayland dev boxes that way (pattern: `wezterm_kitty_keyboard_warning`).
|
|
///
|
|
/// The `is_wayland` short-circuits keep the compositor probe and the tool
|
|
/// probe off non-Wayland sessions. On a Wayland session `native_tool_name()`
|
|
/// resolves to "wl-copy" exactly when wl-copy is installed (it is the first
|
|
/// probe candidate).
|
|
pub fn diagnose_wayland_data_control_live() -> Option<TerminalWarning> {
|
|
let is_wayland = crate::host::DisplayServer::current() == crate::host::DisplayServer::Wayland;
|
|
diagnose_wayland_data_control(
|
|
is_wayland,
|
|
is_wayland && kigi_shell::util::clipboard::wayland_data_control_supported(),
|
|
is_wayland && kigi_shell::util::clipboard::native_tool_name() == "wl-copy",
|
|
)
|
|
}
|
|
|
|
/// `/terminal-setup` Environment `color` row.
|
|
pub fn format_color_env_line(level: ColorLevel) -> String {
|
|
format!(" color {}\n", level.as_str())
|
|
}
|
|
|
|
/// `/terminal-setup` Environment `themes` row (mirrors [`ThemeKind::available`]).
|
|
pub fn format_themes_env_line(level: ColorLevel) -> String {
|
|
if level.has_truecolor() {
|
|
return " themes all\n".to_string();
|
|
}
|
|
let names: Vec<&str> = ThemeKind::ALL
|
|
.iter()
|
|
.filter(|k| !k.requires_truecolor())
|
|
.map(|k| k.display_name())
|
|
.collect();
|
|
format!(
|
|
" themes {}/{}: {}\n",
|
|
names.len(),
|
|
ThemeKind::ALL.len(),
|
|
names.join(", ")
|
|
)
|
|
}
|
|
|
|
/// `/terminal-setup` warning when truecolor themes are locked out.
|
|
///
|
|
/// Not in `collect_startup_warnings` — limited color is normal on some
|
|
/// emulators and would spam the welcome banner.
|
|
pub fn color_support_warning(
|
|
level: ColorLevel,
|
|
brand: TerminalName,
|
|
is_tmux_backed: bool,
|
|
tmux_config_path: &str,
|
|
) -> Option<TerminalWarning> {
|
|
if level.has_truecolor() {
|
|
return None;
|
|
}
|
|
|
|
if level == ColorLevel::None {
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::LimitedColorSupport,
|
|
"NO_COLOR set -- themed colors disabled",
|
|
None,
|
|
None,
|
|
);
|
|
warning.note = Some("Unset NO_COLOR and restart Kigi.".to_string());
|
|
return Some(warning);
|
|
}
|
|
|
|
let level_label = level.as_str();
|
|
|
|
if brand == TerminalName::AppleTerminal {
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::LimitedColorSupport,
|
|
"Terminal.app is 256-color -- truecolor themes unavailable",
|
|
None,
|
|
None,
|
|
);
|
|
warning.note = Some("Switch to a truecolor terminal (e.g. Ghostty).".to_string());
|
|
return Some(warning);
|
|
}
|
|
|
|
if is_tmux_backed {
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::LimitedColorSupport,
|
|
&format!("Color level is {level_label} -- truecolor themes unavailable"),
|
|
Some("set -as terminal-features \",*:RGB\""),
|
|
Some(tmux_config_path),
|
|
);
|
|
warning.note = Some(format!(
|
|
"Also: set -g default-terminal \"tmux-256color\"; export COLORTERM=truecolor; \
|
|
then `tmux source-file {tmux_config_path}`."
|
|
));
|
|
return Some(warning);
|
|
}
|
|
|
|
let mut warning = TerminalWarning::new(
|
|
WarningCategory::LimitedColorSupport,
|
|
&format!("Color level is {level_label} -- truecolor themes unavailable"),
|
|
Some("export COLORTERM=truecolor"),
|
|
None,
|
|
);
|
|
warning.note = Some("Persist in ~/.zshrc / ~/.bashrc and restart Kigi.".to_string());
|
|
Some(warning)
|
|
}
|
|
|
|
/// Returns `true` if the tmux server recognises this option name.
|
|
///
|
|
/// Uses the non-quiet form (`tmux show-option -gv`) which returns non-zero for
|
|
/// unknown options, as opposed to `-q` which always returns 0.
|
|
fn tmux_option_exists(option: &str) -> bool {
|
|
let mut cmd = Command::new("tmux");
|
|
cmd.args(["show-option", "-gv", option])
|
|
.stdin(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::null());
|
|
kigi_tty_utils::detach_std_command(&mut cmd);
|
|
cmd.status().map(|s| s.success()).unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) use crate::terminal::parse_tmux_show_option_output;
|
|
pub(crate) use crate::terminal::tmux_show_option;
|
|
|
|
// ===========================================================================
|
|
// Tests
|
|
// ===========================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::terminal::{
|
|
ByobuBackend, MultiplexerKind, TerminalContext, TerminalName, TmuxClientMeta,
|
|
};
|
|
|
|
// -- Deterministic TmuxOptionQuery fixture --------------------------------
|
|
|
|
/// A fake [`TmuxOptionQuery`] that returns pre-configured values without
|
|
/// touching the tmux subprocess. Use this in all diagnostic tests to avoid
|
|
/// host-state dependencies.
|
|
struct FakeTmuxQuery {
|
|
set_clipboard: Option<String>,
|
|
allow_passthrough_exists: bool,
|
|
allow_passthrough: Option<String>,
|
|
}
|
|
|
|
impl FakeTmuxQuery {
|
|
/// Create a healthy modern tmux fixture (all options correct).
|
|
fn healthy_modern() -> Self {
|
|
Self {
|
|
set_clipboard: Some("on".to_owned()),
|
|
allow_passthrough_exists: true,
|
|
allow_passthrough: Some("on".to_owned()),
|
|
}
|
|
}
|
|
|
|
/// Create a fixture where no tmux server is reachable.
|
|
fn unavailable() -> Self {
|
|
Self {
|
|
set_clipboard: None,
|
|
allow_passthrough_exists: false,
|
|
allow_passthrough: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TmuxOptionQuery for FakeTmuxQuery {
|
|
fn show_option(&self, option: &str) -> Option<String> {
|
|
match option {
|
|
"set-clipboard" => self.set_clipboard.clone(),
|
|
"allow-passthrough" => self.allow_passthrough.clone(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn option_exists(&self, option: &str) -> bool {
|
|
match option {
|
|
"allow-passthrough" => self.allow_passthrough_exists,
|
|
"set-clipboard" => self.set_clipboard.is_some(),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Test context builders ------------------------------------------------
|
|
|
|
fn plain_terminal_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Ghostty,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plain_tmux_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Iterm2,
|
|
multiplexer: MultiplexerKind::Tmux,
|
|
tmux_meta: TmuxClientMeta {
|
|
tmux_env: Some("/tmp/tmux-501/default,12345,0".to_owned()),
|
|
tmux_pane: Some("%0".to_owned()),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn byobu_tmux_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
multiplexer: MultiplexerKind::Tmux,
|
|
byobu: Some(ByobuBackend::Tmux),
|
|
tmux_meta: TmuxClientMeta {
|
|
tmux_env: Some("/tmp/tmux-501/default,12345,0".to_owned()),
|
|
tmux_pane: Some("%1".to_owned()),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn byobu_screen_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
multiplexer: MultiplexerKind::Screen,
|
|
byobu: Some(ByobuBackend::Screen),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn plain_screen_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
multiplexer: MultiplexerKind::Screen,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn apple_terminal_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::AppleTerminal,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn zellij_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::Ghostty,
|
|
multiplexer: MultiplexerKind::Zellij,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// diagnose_clipboard_from_values: pure clipboard logic
|
|
// =====================================================================
|
|
|
|
#[test]
|
|
fn clipboard_all_good_modern_tmux() {
|
|
let w = diagnose_clipboard_from_values(Some("on"), true, Some("on"), "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_all_good_external() {
|
|
let w = diagnose_clipboard_from_values(Some("external"), true, Some("all"), "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_all_good_old_tmux() {
|
|
let w = diagnose_clipboard_from_values(Some("on"), false, None, "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_off_is_flagged() {
|
|
let w = diagnose_clipboard_from_values(Some("off"), true, Some("on"), "~/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[0].fix.as_deref(), Some("set -g set-clipboard on"));
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_query_unavailable_does_not_warn() {
|
|
// `None` means the query failed — not proof that the setting is
|
|
// disabled. No warning should be emitted.
|
|
let w = diagnose_clipboard_from_values(None, true, Some("on"), "~/.tmux.conf");
|
|
assert!(
|
|
w.is_empty(),
|
|
"Query-unavailable set-clipboard should not produce a warning"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dcs_passthrough_off_is_flagged() {
|
|
let w = diagnose_clipboard_from_values(Some("on"), true, Some("off"), "~/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
|
assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on"));
|
|
}
|
|
|
|
#[test]
|
|
fn dcs_passthrough_query_unavailable_does_not_warn() {
|
|
// `allow_passthrough` query returned `None` — the probe could not
|
|
// obtain a value, so we must not claim the setting is disabled.
|
|
let w = diagnose_clipboard_from_values(Some("on"), true, None, "~/.tmux.conf");
|
|
assert!(
|
|
w.is_empty(),
|
|
"Query-unavailable allow-passthrough should not produce a warning"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dcs_passthrough_not_checked_on_old_tmux() {
|
|
let w = diagnose_clipboard_from_values(Some("on"), false, None, "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_both_bad_produces_two_warnings() {
|
|
let w = diagnose_clipboard_from_values(Some("off"), true, Some("off"), "~/.tmux.conf");
|
|
assert_eq!(w.len(), 2);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[1].category, WarningCategory::DcsPassthrough);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_both_bad_old_tmux_produces_one_warning() {
|
|
let w = diagnose_clipboard_from_values(Some("off"), false, None, "~/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_byobu_config_path_propagated() {
|
|
let w =
|
|
diagnose_clipboard_from_values(Some("off"), true, Some("on"), "~/.byobu/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
}
|
|
|
|
// =====================================================================
|
|
// diagnose_wayland_data_control: pure Wayland clipboard logic
|
|
// =====================================================================
|
|
|
|
#[test]
|
|
fn wayland_no_data_control_warns() {
|
|
let w = diagnose_wayland_data_control(true, false, true).expect("must warn");
|
|
assert_eq!(w.category, WarningCategory::WaylandNoDataControl);
|
|
assert!(w.message.contains("focused"));
|
|
assert!(w.fix.is_none(), "wl-copy present: nothing to install");
|
|
}
|
|
|
|
#[test]
|
|
fn wayland_no_data_control_missing_wl_copy_suggests_install() {
|
|
let w = diagnose_wayland_data_control(true, false, false).expect("must warn");
|
|
assert_eq!(w.category, WarningCategory::WaylandNoDataControl);
|
|
assert!(
|
|
w.fix.as_deref().is_some_and(|f| f.contains("wl-clipboard")),
|
|
"fix must suggest installing wl-clipboard, got: {:?}",
|
|
w.fix
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wayland_with_data_control_is_quiet() {
|
|
assert!(diagnose_wayland_data_control(true, true, true).is_none());
|
|
assert!(diagnose_wayland_data_control(true, true, false).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn non_wayland_is_quiet() {
|
|
assert!(diagnose_wayland_data_control(false, false, false).is_none());
|
|
assert!(diagnose_wayland_data_control(false, true, true).is_none());
|
|
}
|
|
|
|
// =====================================================================
|
|
// collect_startup_warnings: full integration
|
|
// =====================================================================
|
|
|
|
// -- Plain terminal: no warnings ------------------------------------------
|
|
|
|
#[test]
|
|
fn plain_terminal_no_warnings() {
|
|
let ctx = plain_terminal_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(w.is_empty(), "Plain terminal should produce no warnings");
|
|
}
|
|
|
|
// -- Healthy tmux: no warnings --------------------------------------------
|
|
|
|
#[test]
|
|
fn healthy_tmux_fullscreen_no_warnings() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(w.is_empty(), "Healthy tmux fullscreen should be quiet");
|
|
}
|
|
|
|
#[test]
|
|
fn healthy_tmux_inline_no_warnings() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, false);
|
|
assert!(w.is_empty(), "Healthy tmux inline should be quiet");
|
|
}
|
|
|
|
// -- tmux clipboard misconfiguration --------------------------------------
|
|
|
|
#[test]
|
|
fn tmux_clipboard_off_warns() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_dcs_passthrough_off_warns() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_both_clipboard_issues_warns_twice() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 2);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[1].category, WarningCategory::DcsPassthrough);
|
|
}
|
|
|
|
// -- tmux control mode ----------------------------------------------------
|
|
|
|
#[test]
|
|
fn tmux_control_mode_inline_warns_degraded() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
// control_mode=true, fullscreen_active=false → inline degraded message.
|
|
let w = collect_startup_warnings(&ctx, &query, true, false);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ControlMode);
|
|
assert!(
|
|
w[0].message.contains("degraded inline"),
|
|
"Inline control-mode should mention degraded inline mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_control_mode_fullscreen_warns_unreliable() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
// control_mode=true, fullscreen_active=true → fullscreen unreliable message.
|
|
let w = collect_startup_warnings(&ctx, &query, true, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ControlMode);
|
|
assert!(
|
|
w[0].message.contains("unreliable"),
|
|
"Fullscreen control-mode should warn about unreliable fullscreen"
|
|
);
|
|
assert!(
|
|
!w[0].message.contains("degraded inline"),
|
|
"Fullscreen control-mode should NOT mention degraded inline mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_control_mode_with_clipboard_issue_shows_both() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, true, false);
|
|
let categories: Vec<_> = w.iter().map(|w| w.category).collect();
|
|
assert!(categories.contains(&WarningCategory::ControlMode));
|
|
assert!(categories.contains(&WarningCategory::Clipboard));
|
|
}
|
|
|
|
// -- Byobu-on-tmux -------------------------------------------------------
|
|
|
|
#[test]
|
|
fn byobu_tmux_healthy_no_warnings() {
|
|
let ctx = byobu_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(w.is_empty(), "Healthy Byobu-tmux should be quiet");
|
|
}
|
|
|
|
#[test]
|
|
fn byobu_tmux_clipboard_off_uses_byobu_config_path() {
|
|
let ctx = byobu_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
}
|
|
|
|
// -- Byobu-on-screen ------------------------------------------------------
|
|
|
|
#[test]
|
|
fn byobu_screen_warns_best_effort() {
|
|
let ctx = byobu_screen_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ByobuScreen);
|
|
assert!(w[0].fix.is_none(), "Byobu-screen has no actionable fix");
|
|
}
|
|
|
|
#[test]
|
|
fn byobu_screen_does_not_show_tmux_warnings() {
|
|
let ctx = byobu_screen_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
// Only the ByobuScreen warning, no clipboard/DCS warnings.
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ByobuScreen);
|
|
}
|
|
|
|
// -- Plain screen (no Byobu) ----------------------------------------------
|
|
|
|
#[test]
|
|
fn plain_screen_no_warnings() {
|
|
let ctx = plain_screen_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Plain screen should not inherit Byobu or tmux warnings"
|
|
);
|
|
}
|
|
|
|
// -- Zellij ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn zellij_no_warnings() {
|
|
let ctx = zellij_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, false);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Zellij should not show tmux or Byobu warnings"
|
|
);
|
|
}
|
|
|
|
// -- Apple Terminal (unsupported OSC 52) ----------------------------------
|
|
|
|
// Note: the Apple Terminal warning is gated on `is_remote_session()` which
|
|
// reads ambient env vars. In CI / local dev, SSH_CONNECTION is typically
|
|
// unset, so `collect_startup_warnings` won't fire this warning. We test
|
|
// the rendering and category independently.
|
|
|
|
#[test]
|
|
fn apple_terminal_warning_category_exists() {
|
|
// Verify the warning renders correctly via the banner.
|
|
let w = [TerminalWarning::new(
|
|
WarningCategory::UnsupportedTerminal,
|
|
"macOS Terminal does not support clipboard escape sequences (OSC 52) \
|
|
— copy over SSH will not work. Use iTerm2 or Ghostty instead",
|
|
None,
|
|
None,
|
|
)];
|
|
assert_eq!(w[0].category, WarningCategory::UnsupportedTerminal);
|
|
assert!(w[0].fix.is_none(), "No fix — user must switch terminals");
|
|
}
|
|
|
|
#[test]
|
|
fn apple_terminal_non_ssh_no_warning() {
|
|
// When not over SSH, Apple Terminal can use pbcopy — no warning needed.
|
|
// Since we can't inject `is_remote_session()`, we verify that the
|
|
// context builder with AppleTerminal + no SSH env doesn't produce
|
|
// the warning in a typical local environment.
|
|
let ctx = apple_terminal_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
// In a non-SSH test environment, this should be empty.
|
|
// (If CI sets SSH_CONNECTION, this test would see the warning — acceptable.)
|
|
let unsupported: Vec<_> = w
|
|
.iter()
|
|
.filter(|w| w.category == WarningCategory::UnsupportedTerminal)
|
|
.collect();
|
|
// We can't assert empty because CI might have SSH_CONNECTION set,
|
|
// but we CAN assert that if the warning fires, it has no fix.
|
|
for warning in &unsupported {
|
|
assert!(warning.fix.is_none());
|
|
}
|
|
}
|
|
|
|
// -- Multi-warning coalescing ---------------------------------------------
|
|
|
|
#[test]
|
|
fn tmux_clipboard_and_dcs_both_warn() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 2);
|
|
let categories: Vec<_> = w.iter().map(|w| w.category).collect();
|
|
assert!(categories.contains(&WarningCategory::Clipboard));
|
|
assert!(categories.contains(&WarningCategory::DcsPassthrough));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_control_mode_with_all_issues() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, true, false);
|
|
let categories: Vec<_> = w.iter().map(|w| w.category).collect();
|
|
assert!(categories.contains(&WarningCategory::ControlMode));
|
|
assert!(categories.contains(&WarningCategory::Clipboard));
|
|
assert!(categories.contains(&WarningCategory::DcsPassthrough));
|
|
}
|
|
|
|
// -- Query unavailable: tmux server unreachable ---------------------------
|
|
|
|
#[test]
|
|
fn tmux_query_unavailable_produces_no_clipboard_warnings() {
|
|
// When the tmux server is unreachable, all queries return `None`.
|
|
// The diagnostics engine must not claim settings are disabled.
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::unavailable();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Unavailable tmux queries should not produce warnings"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_query_unavailable_in_control_mode_only_shows_control_warning() {
|
|
// Even with all queries unavailable, the control-mode warning is
|
|
// purely env-based and should still appear.
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::unavailable();
|
|
let w = collect_startup_warnings(&ctx, &query, true, false);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ControlMode);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_query_unavailable_all_none_no_warnings() {
|
|
// All probes return `None` → no warnings from clipboard diagnostics.
|
|
let w = diagnose_clipboard_from_values(None, false, None, "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_query_unavailable_passthrough_exists_but_value_none() {
|
|
// `option_exists` returned true but `show_option` returned `None`:
|
|
// the server knows the option but couldn't read it. No warning.
|
|
let w = diagnose_clipboard_from_values(Some("on"), true, None, "~/.tmux.conf");
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
// -- tmux_option_exists: deterministic known-bad option --------------------
|
|
|
|
#[test]
|
|
fn tmux_option_exists_returns_false_for_nonexistent_option() {
|
|
assert!(!tmux_option_exists("nonexistent-option-xyz"));
|
|
}
|
|
|
|
// =====================================================================
|
|
// Extended diagnostic matrix (final hardening)
|
|
// =====================================================================
|
|
|
|
// -- Non-standard option values trigger warnings --------------------------
|
|
|
|
#[test]
|
|
fn clipboard_disabled_string_is_flagged() {
|
|
// Some tmux configurations return "disabled" instead of "off".
|
|
let w = diagnose_clipboard_from_values(Some("disabled"), true, Some("on"), "~/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_disabled_string_is_flagged() {
|
|
let w = diagnose_clipboard_from_values(Some("on"), true, Some("disabled"), "~/.tmux.conf");
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
|
}
|
|
|
|
// -- Zellij produces no tmux-specific warnings ----------------------------
|
|
|
|
#[test]
|
|
fn zellij_fullscreen_active_no_warnings() {
|
|
// Zellij with fullscreen_active=true: no tmux warnings should appear.
|
|
let ctx = zellij_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Zellij should not produce warnings even with fullscreen active"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zellij_with_bad_tmux_options_still_quiet() {
|
|
// Even if the fake tmux query reports bad options, Zellij context
|
|
// should not produce tmux-specific warnings because it is not tmux-backed.
|
|
let ctx = zellij_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Zellij should not inherit tmux option warnings"
|
|
);
|
|
}
|
|
|
|
// -- Plain terminal with bad tmux options: no warnings --------------------
|
|
|
|
#[test]
|
|
fn plain_terminal_with_bad_tmux_options_still_quiet() {
|
|
let ctx = plain_terminal_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Plain terminal should not show tmux option warnings"
|
|
);
|
|
}
|
|
|
|
// -- Plain screen with bad tmux options: no warnings ----------------------
|
|
|
|
#[test]
|
|
fn plain_screen_with_bad_tmux_options_no_warnings() {
|
|
let ctx = plain_screen_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Plain screen should not produce tmux-specific warnings"
|
|
);
|
|
}
|
|
|
|
// -- WezTerm without the Kitty keyboard protocol ---------------------------
|
|
|
|
fn wezterm_ctx() -> TerminalContext {
|
|
TerminalContext {
|
|
brand: TerminalName::WezTerm,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_no_kkp_warns_with_config_fix() {
|
|
let w = wezterm_kitty_keyboard_warning(&wezterm_ctx(), false, None)
|
|
.expect("WezTerm without pushed KKP flags must warn");
|
|
assert_eq!(w.category, WarningCategory::WezTermKittyKeyboardOff);
|
|
assert_eq!(
|
|
w.fix.as_deref(),
|
|
Some("config.enable_kitty_keyboard = true")
|
|
);
|
|
assert_eq!(
|
|
w.config_path.as_deref(),
|
|
Some("~/.config/wezterm/wezterm.lua")
|
|
);
|
|
assert!(
|
|
w.note.as_deref().is_some_and(|n| n.contains("\\")),
|
|
"note must mention the backslash+Enter workaround"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_with_kkp_active_no_warning() {
|
|
// Probe passed and flags were pushed (e.g. enable_kitty_keyboard =
|
|
// true already set) — Shift+Enter works, stay quiet.
|
|
assert!(wezterm_kitty_keyboard_warning(&wezterm_ctx(), true, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn non_wezterm_no_kkp_no_warning() {
|
|
// Other brands without KKP are handled by their own paths (hint
|
|
// substitution, brand skip lists) — this warning is WezTerm-only.
|
|
let ctx = plain_terminal_ctx();
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_inside_tmux_with_skip_reason_no_warning() {
|
|
// A kitty skip reason (old tmux) means KKP was never probed; the
|
|
// wezterm.lua change alone wouldn't help, so don't advertise it.
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::WezTerm,
|
|
multiplexer: MultiplexerKind::Tmux,
|
|
..Default::default()
|
|
};
|
|
assert!(ctx.kitty_skip_reason().is_some(), "fixture must skip KKP");
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_over_ssh_via_xtversion_warns() {
|
|
// SSH shape: env brand Unknown (TERM_PROGRAM not forwarded), but
|
|
// the terminal self-reported as WezTerm via XTVERSION. KKP is
|
|
// skipped for Unknown brands, so flags were never pushed — warn.
|
|
let ctx = TerminalContext {
|
|
is_ssh: true,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(ctx.brand, TerminalName::Unknown);
|
|
let w = wezterm_kitty_keyboard_warning(&ctx, false, Some("WezTerm 20240203-110809"))
|
|
.expect("XTVERSION-identified WezTerm over SSH must warn");
|
|
assert_eq!(w.category, WarningCategory::WezTermKittyKeyboardOff);
|
|
// The pager never negotiates KKP for Unknown brands, so the
|
|
// wezterm.lua change cannot fix SSH sessions — the SSH variant
|
|
// must NOT advertise it as the fix, and must lead with the
|
|
// backslash+Enter workaround instead.
|
|
assert!(
|
|
w.fix.is_none(),
|
|
"SSH variant must not advertise a config fix it can't honor"
|
|
);
|
|
assert!(w.message.contains("over SSH"));
|
|
assert!(
|
|
w.note
|
|
.as_deref()
|
|
.is_some_and(|n| n.starts_with("Type `\\`")),
|
|
"SSH note must lead with the backslash+Enter workaround"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_over_ssh_with_kkp_active_no_warning() {
|
|
// Hypothetical future where KKP was negotiated despite the
|
|
// Unknown brand — flags pushed wins over the XTVERSION report.
|
|
let ctx = TerminalContext::default();
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, true, Some("WezTerm 20240203")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_brand_non_wezterm_xtversion_no_warning() {
|
|
// Self-report names a different terminal — stay quiet.
|
|
let ctx = TerminalContext::default();
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, Some("kitty 0.35.2")).is_none());
|
|
// tmux answering XTVERSION must not be mistaken for a brand.
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, Some("tmux 3.4")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn xtversion_wezterm_under_multiplexer_no_warning() {
|
|
// Under tmux the XTVERSION reply describes tmux; even if a stale
|
|
// "WezTerm" payload appeared, the multiplexer gate rejects it.
|
|
let ctx = TerminalContext {
|
|
multiplexer: MultiplexerKind::Tmux,
|
|
..Default::default()
|
|
};
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, Some("WezTerm 2024")).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn xtversion_wezterm_local_not_ssh_no_warning() {
|
|
// Local WezTerm with TERM_PROGRAM stripped (brand falls back to
|
|
// Unknown) can still answer XTVERSION with "WezTerm". The
|
|
// XTVERSION path is SSH-only, so without is_ssh we must NOT emit
|
|
// the "over SSH" copy here -- that would be wrong (it's local) and
|
|
// would drop the actionable wezterm.lua fix. Env-based detection
|
|
// covers the actionable local case; stay quiet otherwise.
|
|
let ctx = TerminalContext {
|
|
is_ssh: false,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(ctx.brand, TerminalName::Unknown);
|
|
assert!(wezterm_kitty_keyboard_warning(&ctx, false, Some("WezTerm 20240203")).is_none());
|
|
}
|
|
|
|
// -- assemble_startup_warnings: banner ordering ----------------------------
|
|
|
|
fn clipboard_banner() -> crate::startup::StartupWarning {
|
|
crate::startup::StartupWarning {
|
|
severity: crate::startup::WarningSeverity::Warning,
|
|
message: "Clipboard may be unreachable.".to_string(),
|
|
action: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_banner_goes_first() {
|
|
// The welcome screen renders only the first warning; the WezTerm
|
|
// banner (broken local input) must displace clipboard advisories.
|
|
let w = wezterm_kitty_keyboard_warning(&wezterm_ctx(), false, None).unwrap();
|
|
let out = assemble_startup_warnings(Some(&w), None, None, vec![clipboard_banner()]);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(
|
|
out[0].message.contains("WezTerm"),
|
|
"WezTerm banner must be first, got: {}",
|
|
out[0].message
|
|
);
|
|
assert!(out[1].message.contains("Clipboard"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_wezterm_warning_leaves_summarized_untouched() {
|
|
let out = assemble_startup_warnings(None, None, None, vec![clipboard_banner()]);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(out[0].message.contains("Clipboard"));
|
|
}
|
|
|
|
#[test]
|
|
fn wayland_banner_surfaces_without_ssh_gate() {
|
|
// `summarize_warnings` is SSH-gated, so the Wayland warning reaches the
|
|
// welcome banner through the assemble bypass instead.
|
|
let w = diagnose_wayland_data_control(true, false, true).unwrap();
|
|
let out = assemble_startup_warnings(None, Some(&w), None, vec![clipboard_banner()]);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(
|
|
out[0].message.contains("focused"),
|
|
"Wayland banner must be first, got: {}",
|
|
out[0].message
|
|
);
|
|
assert!(out[1].message.contains("Clipboard"));
|
|
}
|
|
|
|
#[test]
|
|
fn wezterm_banner_outranks_wayland_banner() {
|
|
let wez = wezterm_kitty_keyboard_warning(&wezterm_ctx(), false, None).unwrap();
|
|
let way = diagnose_wayland_data_control(true, false, true).unwrap();
|
|
let out = assemble_startup_warnings(Some(&wez), Some(&way), None, vec![clipboard_banner()]);
|
|
assert_eq!(out.len(), 3);
|
|
assert!(out[0].message.contains("WezTerm"));
|
|
assert!(out[1].message.contains("focused"));
|
|
assert!(out[2].message.contains("Clipboard"));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_profile_conflict_warning_reports_conflicts() {
|
|
assert!(sandbox_profile_conflict_warning_from(vec![]).is_none());
|
|
|
|
let w = sandbox_profile_conflict_warning_from(vec!["dev".to_string()]).unwrap();
|
|
assert_eq!(w.category, WarningCategory::SandboxProfileConflict);
|
|
assert!(
|
|
w.message
|
|
.starts_with("Your project sandbox profile conflicts with user config.")
|
|
);
|
|
assert!(w.message.contains("Profile: 'dev'"));
|
|
assert_eq!(w.fix.as_deref(), Some("Using the user profile instead."));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_banner_sits_below_terminal_banners() {
|
|
let sandbox = sandbox_profile_conflict_warning_from(vec!["dev".to_string()]).unwrap();
|
|
|
|
let out = assemble_startup_warnings(None, None, Some(&sandbox), vec![]);
|
|
assert_eq!(out.len(), 1);
|
|
assert!(out[0].message.contains("sandbox profile"));
|
|
|
|
let wez = wezterm_kitty_keyboard_warning(&wezterm_ctx(), false, None).unwrap();
|
|
let out = assemble_startup_warnings(Some(&wez), None, Some(&sandbox), vec![]);
|
|
assert_eq!(out.len(), 2);
|
|
assert!(out[0].message.contains("WezTerm"));
|
|
assert!(out[1].message.contains("sandbox profile"));
|
|
}
|
|
|
|
// -- Warning ordering: clipboard before DCS --------------------------------
|
|
|
|
#[test]
|
|
fn warning_order_clipboard_then_dcs() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 2);
|
|
assert_eq!(w[0].category, WarningCategory::Clipboard);
|
|
assert_eq!(w[1].category, WarningCategory::DcsPassthrough);
|
|
}
|
|
|
|
#[test]
|
|
fn control_mode_warning_comes_before_clipboard() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, true, false);
|
|
assert_eq!(w.len(), 2);
|
|
assert_eq!(w[0].category, WarningCategory::ControlMode);
|
|
assert_eq!(w[1].category, WarningCategory::Clipboard);
|
|
}
|
|
|
|
// -- Byobu-tmux DCS passthrough uses Byobu config path --------------------
|
|
|
|
#[test]
|
|
fn byobu_tmux_dcs_passthrough_uses_byobu_config_path() {
|
|
let ctx = byobu_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
}
|
|
|
|
// -- Byobu-screen ignores control mode flag (no tmux to be in control mode)
|
|
|
|
#[test]
|
|
fn byobu_screen_ignores_control_mode_flag() {
|
|
let ctx = byobu_screen_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
// Even with control_mode=true, Byobu-screen produces only ByobuScreen warning.
|
|
let w = collect_startup_warnings(&ctx, &query, true, true);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::ByobuScreen);
|
|
}
|
|
|
|
// -- Byobu-tmux with all issues: complete coalesced set -------------------
|
|
|
|
#[test]
|
|
fn byobu_tmux_all_issues_fullscreen() {
|
|
let ctx = byobu_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
set_clipboard: Some("off".to_owned()),
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_startup_warnings(&ctx, &query, false, true);
|
|
assert_eq!(w.len(), 2);
|
|
let categories: Vec<_> = w.iter().map(|w| w.category).collect();
|
|
assert!(categories.contains(&WarningCategory::Clipboard));
|
|
assert!(categories.contains(&WarningCategory::DcsPassthrough));
|
|
// All should point to Byobu config.
|
|
for warning in &w {
|
|
assert_eq!(warning.config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
}
|
|
}
|
|
|
|
// -- tmux extended-keys off warning ---------------------------------------
|
|
|
|
fn extended_keys_ctx(base: TerminalContext, val: Option<&str>) -> TerminalContext {
|
|
TerminalContext {
|
|
tmux_version: Some("tmux 3.4".to_owned()),
|
|
tmux_extended_keys: val.map(str::to_owned),
|
|
..base
|
|
}
|
|
}
|
|
|
|
fn collect_extended_keys_warnings(ctx: &TerminalContext) -> Vec<TerminalWarning> {
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
collect_startup_warnings(ctx, &query, false, true)
|
|
.into_iter()
|
|
.filter(|w| w.category == WarningCategory::TmuxExtendedKeysOff)
|
|
.collect()
|
|
}
|
|
|
|
fn assert_no_extended_keys_warning(val: Option<&str>) {
|
|
let ctx = extended_keys_ctx(plain_tmux_ctx(), val);
|
|
assert!(collect_extended_keys_warnings(&ctx).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_extended_keys_off_emits_warning() {
|
|
let ctx = extended_keys_ctx(plain_tmux_ctx(), Some("off"));
|
|
let warnings = collect_extended_keys_warnings(&ctx);
|
|
let extended = warnings.first().expect("warning must fire");
|
|
assert_eq!(
|
|
extended.message,
|
|
"tmux extended-keys is off -- modifier key combinations may not reach the pager"
|
|
);
|
|
assert_eq!(extended.fix.as_deref(), Some("set -g extended-keys on"));
|
|
assert_eq!(extended.config_path.as_deref(), Some("~/.tmux.conf"));
|
|
let note = extended.note.as_deref().expect("note must be present");
|
|
assert!(note.contains("source-file") || note.contains("reattach"));
|
|
assert!(note.contains("~/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_extended_keys_off_uses_byobu_config_path() {
|
|
let ctx = extended_keys_ctx(byobu_tmux_ctx(), Some("off"));
|
|
let warnings = collect_extended_keys_warnings(&ctx);
|
|
let extended = warnings.first().expect("warning must fire");
|
|
assert_eq!(extended.config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
let note = extended.note.as_deref().expect("note must be present");
|
|
assert!(note.contains("~/.byobu/.tmux.conf"));
|
|
// `~/.byobu/.tmux.conf` does not contain `~/.tmux.conf` as a
|
|
// contiguous substring, so this catches the hardcoded-path
|
|
// bug.
|
|
assert!(!note.contains("~/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn tmux_extended_keys_no_warning_for_non_off_values() {
|
|
assert_no_extended_keys_warning(None);
|
|
assert_no_extended_keys_warning(Some("on"));
|
|
assert_no_extended_keys_warning(Some("always"));
|
|
}
|
|
|
|
// -- summarize_warnings allow-list -----------------------------------------
|
|
|
|
#[test]
|
|
fn summarize_warnings_surfaces_extended_keys_off() {
|
|
let ctx = extended_keys_ctx(plain_tmux_ctx(), Some("off"));
|
|
let warnings = collect_extended_keys_warnings(&ctx);
|
|
let summary =
|
|
summarize_warnings_inner(&warnings, true).expect("welcome banner must surface");
|
|
assert_eq!(summary.severity, crate::startup::WarningSeverity::Warning);
|
|
assert_eq!(summary.message, "Clipboard may be unreachable.");
|
|
assert_eq!(
|
|
summary.action.as_deref(),
|
|
Some("See /terminal-setup for potential fixes.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_surfaces_dcs_passthrough_off() {
|
|
let warnings =
|
|
diagnose_clipboard_from_values(Some("on"), true, Some("off"), "~/.tmux.conf");
|
|
let summary =
|
|
summarize_warnings_inner(&warnings, true).expect("welcome banner must surface");
|
|
assert_eq!(summary.severity, crate::startup::WarningSeverity::Warning);
|
|
assert_eq!(summary.message, "Clipboard may be unreachable.");
|
|
assert_eq!(
|
|
summary.action.as_deref(),
|
|
Some("See /terminal-setup for potential fixes.")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_suppresses_other_categories() {
|
|
// Clipboard warnings stay suppressed: the welcome-banner allow-list
|
|
// is intentionally narrow until each category's false-positive rate
|
|
// is characterized.
|
|
let warnings = diagnose_clipboard_from_values(Some("off"), false, None, "~/.tmux.conf");
|
|
assert!(
|
|
!warnings.is_empty(),
|
|
"fixture sanity: clipboard warnings must fire"
|
|
);
|
|
assert!(summarize_warnings_inner(&warnings, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_picks_allowed_when_mixed_with_others() {
|
|
// When clipboard (suppressed) AND DCS passthrough (allowed) both
|
|
// fire, the banner still surfaces.
|
|
let warnings =
|
|
diagnose_clipboard_from_values(Some("off"), true, Some("off"), "~/.tmux.conf");
|
|
assert!(
|
|
warnings.len() >= 2,
|
|
"fixture sanity: multiple warnings must fire"
|
|
);
|
|
let summary = summarize_warnings_inner(&warnings, true).expect("surfaces allowed warning");
|
|
assert_eq!(summary.message, "Clipboard may be unreachable.");
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_empty_input_returns_none() {
|
|
assert!(summarize_warnings_inner(&[], true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_suppressed_when_not_ssh() {
|
|
// Even with a valid allow-listed warning, the banner is suppressed
|
|
// when not running over SSH — locally these misconfigurations don't
|
|
// actually break clipboard.
|
|
let ctx = extended_keys_ctx(plain_tmux_ctx(), Some("off"));
|
|
let warnings = collect_extended_keys_warnings(&ctx);
|
|
assert!(
|
|
!warnings.is_empty(),
|
|
"fixture sanity: extended-keys warning must fire"
|
|
);
|
|
assert!(summarize_warnings_inner(&warnings, false).is_none());
|
|
}
|
|
|
|
// -- parse_tmux_show_option_output (pure helper) --------------------------
|
|
|
|
#[test]
|
|
fn parse_tmux_show_option_output_success_cases() {
|
|
assert_eq!(
|
|
parse_tmux_show_option_output(true, b"on\n"),
|
|
Some("on".to_owned()),
|
|
);
|
|
assert_eq!(
|
|
parse_tmux_show_option_output(true, b"off"),
|
|
Some("off".to_owned()),
|
|
);
|
|
// Symmetric trim: leading whitespace is stripped too.
|
|
assert_eq!(
|
|
parse_tmux_show_option_output(true, b" on"),
|
|
Some("on".to_owned()),
|
|
);
|
|
assert_eq!(
|
|
parse_tmux_show_option_output(true, b"\tor"),
|
|
Some("or".to_owned()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_tmux_show_option_output_collapses_to_none() {
|
|
assert_eq!(parse_tmux_show_option_output(true, b""), None);
|
|
assert_eq!(parse_tmux_show_option_output(true, b"\n"), None);
|
|
assert_eq!(parse_tmux_show_option_output(true, b" "), None);
|
|
// Subprocess failure ignores stdout entirely.
|
|
assert_eq!(parse_tmux_show_option_output(false, b"on"), None);
|
|
}
|
|
|
|
// =====================================================================
|
|
// collect_notification_warnings
|
|
// =====================================================================
|
|
|
|
use crate::notifications::NotificationCondition;
|
|
use crate::notifications::protocol::NotificationProtocol;
|
|
|
|
#[test]
|
|
fn notification_bel_fallback_for_unknown_terminal() {
|
|
let ctx = plain_terminal_ctx();
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
..ctx
|
|
};
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::NotificationProtocolFallback);
|
|
assert!(w[0].message.contains("BEL"));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_bel_for_known_terminal_no_warning() {
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::Alacritty,
|
|
..Default::default()
|
|
};
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(w.is_empty(), "BEL on known terminal should not warn");
|
|
}
|
|
|
|
#[test]
|
|
fn notification_osc_protocol_no_warning_without_tmux() {
|
|
let ctx = plain_terminal_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc99,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn notification_tmux_passthrough_off_warns_for_osc_protocol() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc9,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].category, WarningCategory::DcsPassthrough);
|
|
assert!(w[0].message.contains("notification"));
|
|
assert_eq!(w[0].fix.as_deref(), Some("set -g allow-passthrough on"));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_tmux_passthrough_on_no_warning() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc9,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(w.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn notification_tmux_passthrough_bel_protocol_no_warning() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(w.is_empty(), "BEL does not use DCS passthrough");
|
|
}
|
|
|
|
#[test]
|
|
fn notification_focus_tracking_unavailable_unknown_terminal() {
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
..Default::default()
|
|
};
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Unfocused,
|
|
&query,
|
|
);
|
|
let focus_warnings: Vec<_> = w
|
|
.iter()
|
|
.filter(|w| w.category == WarningCategory::FocusTrackingUnavailable)
|
|
.collect();
|
|
assert_eq!(focus_warnings.len(), 1);
|
|
assert!(focus_warnings[0].message.contains("focus tracking"));
|
|
assert!(focus_warnings[0].fix.as_deref().unwrap().contains("always"));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_focus_tracking_unavailable_apple_terminal() {
|
|
let ctx = apple_terminal_ctx();
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Unfocused,
|
|
&query,
|
|
);
|
|
let focus_warnings: Vec<_> = w
|
|
.iter()
|
|
.filter(|w| w.category == WarningCategory::FocusTrackingUnavailable)
|
|
.collect();
|
|
assert_eq!(focus_warnings.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_focus_tracking_no_warning_when_condition_always() {
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
..Default::default()
|
|
};
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(
|
|
!w.iter()
|
|
.any(|w| w.category == WarningCategory::FocusTrackingUnavailable),
|
|
"condition=always does not need focus tracking"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_focus_tracking_no_warning_for_supported_terminal() {
|
|
let ctx = plain_terminal_ctx(); // Ghostty supports focus tracking
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc777,
|
|
NotificationCondition::Unfocused,
|
|
&query,
|
|
);
|
|
assert!(
|
|
!w.iter()
|
|
.any(|w| w.category == WarningCategory::FocusTrackingUnavailable),
|
|
"Ghostty supports focus tracking"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_multiple_warnings_can_coexist() {
|
|
// Unknown terminal in tmux with passthrough off and unfocused condition
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::Unknown,
|
|
multiplexer: MultiplexerKind::Tmux,
|
|
tmux_meta: TmuxClientMeta {
|
|
tmux_env: Some("/tmp/tmux-501/default,12345,0".to_owned()),
|
|
tmux_pane: Some("%0".to_owned()),
|
|
},
|
|
..Default::default()
|
|
};
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
// BEL on unknown + unfocused → fallback + focus tracking warnings
|
|
// (BEL doesn't use passthrough, so no passthrough warning)
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Bel,
|
|
NotificationCondition::Unfocused,
|
|
&query,
|
|
);
|
|
let categories: Vec<_> = w.iter().map(|w| w.category).collect();
|
|
assert!(categories.contains(&WarningCategory::NotificationProtocolFallback));
|
|
assert!(categories.contains(&WarningCategory::FocusTrackingUnavailable));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_tmux_passthrough_uses_byobu_config_path() {
|
|
let ctx = byobu_tmux_ctx();
|
|
let query = FakeTmuxQuery {
|
|
allow_passthrough: Some("off".to_owned()),
|
|
..FakeTmuxQuery::healthy_modern()
|
|
};
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc9,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert_eq!(w.len(), 1);
|
|
assert_eq!(w[0].config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
}
|
|
|
|
#[test]
|
|
fn notification_query_unavailable_no_passthrough_warning() {
|
|
let ctx = plain_tmux_ctx();
|
|
let query = FakeTmuxQuery::unavailable();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::Osc99,
|
|
NotificationCondition::Always,
|
|
&query,
|
|
);
|
|
assert!(
|
|
w.is_empty(),
|
|
"Unavailable tmux queries should not produce notification warnings"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn notification_none_protocol_no_warnings() {
|
|
let ctx = TerminalContext {
|
|
brand: TerminalName::KigiDesktop,
|
|
..Default::default()
|
|
};
|
|
let query = FakeTmuxQuery::healthy_modern();
|
|
let w = collect_notification_warnings(
|
|
&ctx,
|
|
NotificationProtocol::None,
|
|
NotificationCondition::Unfocused,
|
|
&query,
|
|
);
|
|
assert!(w.is_empty(), "None protocol should produce no warnings");
|
|
}
|
|
|
|
#[test]
|
|
fn supports_focus_tracking_known_terminals() {
|
|
assert!(supports_focus_tracking(TerminalName::Kitty));
|
|
assert!(supports_focus_tracking(TerminalName::Ghostty));
|
|
assert!(supports_focus_tracking(TerminalName::Iterm2));
|
|
assert!(supports_focus_tracking(TerminalName::WezTerm));
|
|
assert!(supports_focus_tracking(TerminalName::Alacritty));
|
|
assert!(supports_focus_tracking(TerminalName::Vte));
|
|
assert!(supports_focus_tracking(TerminalName::Terminator));
|
|
assert!(supports_focus_tracking(TerminalName::WarpTerminal));
|
|
assert!(supports_focus_tracking(TerminalName::VsCode));
|
|
assert!(supports_focus_tracking(TerminalName::KigiDesktop));
|
|
assert!(!supports_focus_tracking(TerminalName::AppleTerminal));
|
|
assert!(!supports_focus_tracking(TerminalName::Unknown));
|
|
assert!(!supports_focus_tracking(TerminalName::Otty));
|
|
}
|
|
|
|
// -- Color / theme rows + LimitedColorSupport warnings --------------------
|
|
|
|
#[test]
|
|
fn format_color_env_line_uses_canonical_level_label() {
|
|
assert_eq!(
|
|
format_color_env_line(ColorLevel::TrueColor),
|
|
" color truecolor\n"
|
|
);
|
|
assert_eq!(
|
|
format_color_env_line(ColorLevel::Ansi256),
|
|
" color 256\n"
|
|
);
|
|
assert_eq!(
|
|
format_color_env_line(ColorLevel::Basic),
|
|
" color basic\n"
|
|
);
|
|
assert_eq!(
|
|
format_color_env_line(ColorLevel::None),
|
|
" color none\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_themes_env_line_all_on_truecolor() {
|
|
assert_eq!(
|
|
format_themes_env_line(ColorLevel::TrueColor),
|
|
" themes all\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_themes_env_line_lists_available_below_truecolor() {
|
|
let n = ThemeKind::ALL
|
|
.iter()
|
|
.filter(|k| !k.requires_truecolor())
|
|
.count();
|
|
let total = ThemeKind::ALL.len();
|
|
for level in [ColorLevel::Ansi256, ColorLevel::Basic, ColorLevel::None] {
|
|
let line = format_themes_env_line(level);
|
|
assert!(
|
|
line.starts_with(&format!(" themes {n}/{total}: ")),
|
|
"level {level:?}: {line}"
|
|
);
|
|
assert!(line.contains("kiginight") && line.contains("kigiday"));
|
|
assert!(!line.contains("tokyonight"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn color_support_warning_none_on_truecolor() {
|
|
assert!(
|
|
color_support_warning(
|
|
ColorLevel::TrueColor,
|
|
TerminalName::Ghostty,
|
|
false,
|
|
"~/.tmux.conf"
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn color_support_warning_no_color() {
|
|
let w = color_support_warning(
|
|
ColorLevel::None,
|
|
TerminalName::Ghostty,
|
|
false,
|
|
"~/.tmux.conf",
|
|
)
|
|
.expect("warn");
|
|
assert_eq!(w.category, WarningCategory::LimitedColorSupport);
|
|
assert!(w.message.contains("NO_COLOR"));
|
|
assert!(w.note.as_deref().is_some_and(|n| n.contains("NO_COLOR")));
|
|
}
|
|
|
|
#[test]
|
|
fn color_support_warning_apple_terminal() {
|
|
let w = color_support_warning(
|
|
ColorLevel::Ansi256,
|
|
TerminalName::AppleTerminal,
|
|
false,
|
|
"~/.tmux.conf",
|
|
)
|
|
.expect("warn");
|
|
assert_eq!(w.category, WarningCategory::LimitedColorSupport);
|
|
assert!(w.message.contains("Terminal.app"));
|
|
assert!(w.fix.is_none());
|
|
assert!(
|
|
w.note
|
|
.as_deref()
|
|
.is_some_and(|n| n.contains("e.g. Ghostty"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn color_support_warning_tmux() {
|
|
let w = color_support_warning(
|
|
ColorLevel::Ansi256,
|
|
TerminalName::Unknown,
|
|
true,
|
|
"~/.byobu/.tmux.conf",
|
|
)
|
|
.expect("warn");
|
|
assert_eq!(w.category, WarningCategory::LimitedColorSupport);
|
|
assert_eq!(
|
|
w.fix.as_deref(),
|
|
Some("set -as terminal-features \",*:RGB\"")
|
|
);
|
|
assert_eq!(w.config_path.as_deref(), Some("~/.byobu/.tmux.conf"));
|
|
let note = w.note.as_deref().expect("note");
|
|
assert!(note.contains("COLORTERM=truecolor") && note.contains("tmux-256color"));
|
|
}
|
|
|
|
#[test]
|
|
fn color_support_warning_colorterm() {
|
|
let w = color_support_warning(
|
|
ColorLevel::Basic,
|
|
TerminalName::Unknown,
|
|
false,
|
|
"~/.tmux.conf",
|
|
)
|
|
.expect("warn");
|
|
assert_eq!(w.fix.as_deref(), Some("export COLORTERM=truecolor"));
|
|
assert!(w.config_path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn summarize_warnings_suppresses_limited_color_support() {
|
|
let w = color_support_warning(
|
|
ColorLevel::Ansi256,
|
|
TerminalName::Unknown,
|
|
false,
|
|
"~/.tmux.conf",
|
|
)
|
|
.expect("fixture");
|
|
assert!(summarize_warnings_inner(&[w], true).is_none());
|
|
}
|
|
}
|