use color_eyre::eyre::Result;
use std::io::IsTerminal;
use std::path::PathBuf;
use crate::app::ctrl_h::{self, CtrlHPolicy};
use crate::cli::helpers::load_settings;
use crate::keys::reachability::{Reach, TerminalKeys, reach, unreachable_actions};
use crate::settings::CtrlHSetting;
pub fn run(config_path: Option<PathBuf>) -> Result<()> {
let settings = load_settings(config_path)?;
let is_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let enhanced =
is_tty && ratatui::crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false);
let erase = if is_tty {
ctrl_h::tty_erase_char()
} else {
None
};
let policy = CtrlHPolicy::resolve_with(settings.ctrl_h, enhanced, erase == Some(0x08));
let keys = TerminalKeys::detected(enhanced, policy == CtrlHPolicy::Backspace);
println!("kimün {}", env!("CARGO_PKG_VERSION"));
println!();
println!("Terminal");
println!(" TERM {}", env_or_unset("TERM"));
println!(" TERM_PROGRAM {}", env_or_unset("TERM_PROGRAM"));
if is_tty {
println!(
" kitty keyboard {}",
if keys.enhanced && !enhanced {
"not needed — the Windows console reports keys, not bytes"
} else if enhanced {
"supported — keys arrive unambiguously"
} else {
"no reply — Ctrl chords share bytes with Tab, Enter and Esc"
}
);
println!(" tty erase character {}", describe_erase(erase));
println!(
" ctrl_h = {:<12} {}",
format!("{:?}", settings.ctrl_h).to_lowercase(),
describe_policy(settings.ctrl_h, policy, enhanced, erase)
);
} else {
println!(" kitty keyboard not asked — output is redirected");
println!(" tty erase character not read — output is redirected");
println!();
println!(" Run `kimun doctor` directly in the terminal you use kimün in");
println!(" to get these two answers. Until then the table below assumes");
if keys.enhanced {
println!(" Windows: the console reports keys, not bytes, so no");
println!(" chord collides.");
} else {
println!(" the worst case: no protocol, so Ctrl chords collide.");
}
}
println!();
println!("Key bindings");
let map = settings.key_bindings.to_hashmap();
let mut rows: Vec<(String, Vec<String>)> = map
.into_iter()
.map(|(action, combos)| {
let fates = combos
.iter()
.map(|c| match reach(*c, keys) {
Reach::Ok => c.to_string(),
fate => format!("{c} ({fate})"),
})
.collect();
(action.to_string(), fates)
})
.collect();
rows.sort();
for (action, fates) in &rows {
println!(" {action:<22} {}", fates.join(" · "));
}
println!();
let stranded = unreachable_actions(&settings.key_bindings, keys);
if stranded.is_empty() {
println!("Every bound action has a key this terminal can send.");
} else {
for u in &stranded {
println!("! {} has no key this terminal can send.", u.action);
}
println!();
println!("{}", stranded_advice(settings.ctrl_h, policy));
}
Ok(())
}
fn env_or_unset(var: &str) -> String {
std::env::var(var).unwrap_or_else(|_| "(unset)".to_string())
}
fn describe_erase(erase: Option<u8>) -> String {
match erase {
Some(0x7f) => "^? (0x7f) — the usual setting".to_string(),
Some(0x08) => "^H (0x08) — same byte as Ctrl+H".to_string(),
Some(c) => format!("0x{c:02x}"),
None => "could not be read".to_string(),
}
}
fn describe_policy(
setting: CtrlHSetting,
policy: CtrlHPolicy,
enhanced: bool,
erase: Option<u8>,
) -> String {
match policy {
CtrlHPolicy::Backspace => "0x08 is delivered as Backspace; the Ctrl+H chord is unreachable",
CtrlHPolicy::Chord if enhanced => "Ctrl+H is a chord (the protocol keeps the keys apart)",
CtrlHPolicy::Chord if setting == CtrlHSetting::Auto && erase == Some(0x08) => {
"Ctrl+H is a chord, though the erase character suggests otherwise"
}
CtrlHPolicy::Chord if setting == CtrlHSetting::Auto => {
"Ctrl+H is a chord (the tty's erase character is not 0x08)"
}
CtrlHPolicy::Chord => "Ctrl+H is a chord",
}
.to_string()
}
fn stranded_advice(setting: CtrlHSetting, policy: CtrlHPolicy) -> &'static str {
match (policy, setting) {
(CtrlHPolicy::Backspace, CtrlHSetting::Backspace) => {
"ctrl_h = \"backspace\" gives up the Ctrl+H chord, so any action\n\
bound only to it is listed above. Rebind those you use."
}
(CtrlHPolicy::Backspace, _) => {
"ctrl_h = \"auto\" chose Backspace because the tty's erase character\n\
is ^H, so the Ctrl+H chord is unreachable and any action bound only\n\
to it is listed above. Set ctrl_h = \"chord\" to keep the chord (a\n\
^H Backspace key then cannot delete), or rebind those actions."
}
(CtrlHPolicy::Chord, _) => {
"Rebind those actions, or use a terminal that speaks the kitty\n\
keyboard protocol (Kitty, Ghostty, foot, WezTerm with\n\
enable_kitty_keyboard = true)."
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_erase_line_names_both_conventions() {
assert!(describe_erase(Some(0x7f)).contains("^?"));
let bs = describe_erase(Some(0x08));
assert!(bs.contains("^H"), "{bs}");
assert!(bs.contains("Ctrl+H"), "{bs}");
assert_eq!(describe_erase(Some(0x15)), "0x15");
assert!(describe_erase(None).contains("could not be read"));
}
#[test]
fn the_policy_line_gives_the_real_reason() {
let backspace = describe_policy(
CtrlHSetting::Auto,
CtrlHPolicy::Backspace,
false,
Some(0x08),
);
assert!(backspace.contains("unreachable"), "{backspace}");
let protocol = describe_policy(CtrlHSetting::Auto, CtrlHPolicy::Chord, true, Some(0x7f));
assert!(protocol.contains("protocol"), "{protocol}");
let erase = describe_policy(CtrlHSetting::Auto, CtrlHPolicy::Chord, false, Some(0x7f));
assert!(erase.contains("erase character is not 0x08"), "{erase}");
let explicit = describe_policy(CtrlHSetting::Chord, CtrlHPolicy::Chord, false, Some(0x08));
assert_eq!(explicit, "Ctrl+H is a chord");
}
#[test]
fn the_advice_names_ctrl_h_under_auto() {
let auto = stranded_advice(CtrlHSetting::Auto, CtrlHPolicy::Backspace);
assert!(auto.contains("ctrl_h = \"auto\""), "{auto}");
assert!(auto.contains("ctrl_h = \"chord\""), "{auto}");
let explicit = stranded_advice(CtrlHSetting::Backspace, CtrlHPolicy::Backspace);
assert!(explicit.contains("ctrl_h = \"backspace\""), "{explicit}");
let chord = stranded_advice(CtrlHSetting::Auto, CtrlHPolicy::Chord);
assert!(chord.contains("kitty"), "{chord}");
}
}