chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "jcode-setup-hints"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
name = "jcode_setup_hints"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
dirs = "5"
jcode-build-meta = { path = "../jcode-build-meta" }
jcode-config-types = { path = "../jcode-config-types" }
jcode-logging = { path = "../jcode-logging" }
jcode-storage = { path = "../jcode-storage" }
jcode-terminal-launch = { path = "../jcode-terminal-launch" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
[target.'cfg(target_os = "macos")'.dependencies]
global-hotkey = "0.7"
[dev-dependencies]
tempfile = "3"
@@ -0,0 +1,342 @@
//! A normalized, platform-independent representation of a key chord.
//!
//! Both jcode's own bindings and the bindings we discover on the machine
//! (terminal config, macOS system hotkeys) are reduced to a [`KeyChord`] so they
//! can be compared for conflicts regardless of where they came from.
use serde::{Deserialize, Serialize};
/// A single key combination: a set of modifiers plus one primary key token.
///
/// The `key` token is stored in a canonical lowercase form (see
/// [`KeyChord::normalize_key`]). Modifiers use jcode's vocabulary where the
/// macOS Command key maps to `cmd` (equivalent to crossterm's `SUPER`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyChord {
#[serde(default, skip_serializing_if = "is_false")]
pub cmd: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub ctrl: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub alt: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub shift: bool,
pub key: String,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl KeyChord {
/// Build a chord from a raw key token, normalizing the token.
pub fn new(cmd: bool, ctrl: bool, alt: bool, shift: bool, key: &str) -> Self {
Self {
cmd,
ctrl,
alt,
shift,
key: Self::normalize_key(key),
}
}
/// A stable, human-readable canonical string such as `cmd+shift+k` or
/// `ctrl+[`. Modifier order is fixed (cmd, ctrl, alt, shift) so two chords
/// that mean the same thing always produce the same string.
pub fn canonical(&self) -> String {
let mut out = String::new();
if self.cmd {
out.push_str("cmd+");
}
if self.ctrl {
out.push_str("ctrl+");
}
if self.alt {
out.push_str("alt+");
}
if self.shift {
out.push_str("shift+");
}
out.push_str(&self.key);
out
}
/// A prettier label for user-facing messages, e.g. `Cmd+Shift+K`.
pub fn display(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.cmd {
parts.push("Cmd".to_string());
}
if self.ctrl {
parts.push("Ctrl".to_string());
}
if self.alt {
parts.push("Alt".to_string());
}
if self.shift {
parts.push("Shift".to_string());
}
parts.push(pretty_key(&self.key));
parts.join("+")
}
/// Like [`KeyChord::display`] but renders the `cmd` modifier as `Super`
/// (the Linux/Wayland convention), e.g. `Super+;` or `Super+Shift+'`.
/// Punctuation keys render as their literal character instead of the XKB
/// name, so `Super+Semicolon` becomes `Super+;`.
pub fn display_super(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.cmd {
parts.push("Super".to_string());
}
if self.ctrl {
parts.push("Ctrl".to_string());
}
if self.alt {
parts.push("Alt".to_string());
}
if self.shift {
parts.push("Shift".to_string());
}
parts.push(pretty_key(&self.key));
parts.join("+")
}
/// Compact macOS-style symbol form, e.g. `⌘;` or `⌘⇧K`. Modifier symbols
/// follow the standard Apple ordering (⌃⌥⇧⌘ is conventional, but we keep
/// jcode's cmd-first canonical order for consistency with `display`).
pub fn display_symbols(&self) -> String {
let mut out = String::new();
if self.ctrl {
out.push('⌃');
}
if self.alt {
out.push('⌥');
}
if self.shift {
out.push('⇧');
}
if self.cmd {
out.push('⌘');
}
out.push_str(&pretty_key(&self.key));
out
}
/// Parse a jcode-style binding string such as `ctrl+k`, `alt+right`, or
/// `cmd+shift+[` into a chord. Mirrors jcode's own keybinding grammar so the
/// conflict detector compares like with like. Returns `None` for empty or
/// explicitly-disabled bindings (`none`/`off`/`disabled`).
pub fn parse(raw: &str) -> Option<Self> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if matches!(
raw.to_ascii_lowercase().as_str(),
"none" | "off" | "disabled"
) {
return None;
}
let mut cmd = false;
let mut ctrl = false;
let mut alt = false;
let mut shift = false;
let mut key: Option<String> = None;
for part in raw.split('+').map(str::trim).filter(|s| !s.is_empty()) {
match part.to_ascii_lowercase().as_str() {
"ctrl" | "control" => ctrl = true,
// jcode treats alt/option/meta as Alt.
"alt" | "option" | "meta" => alt = true,
"cmd" | "command" | "super" | "win" | "windows" => cmd = true,
"shift" => shift = true,
// "backtab" / "shift-tab" imply Shift+Tab.
"backtab" | "shift-tab" => {
shift = true;
key = Some("tab".to_string());
}
other => key = Some(other.to_string()),
}
}
let key = key?;
Some(Self::new(cmd, ctrl, alt, shift, &key))
}
/// Normalize a raw key token (from any source) into a canonical token.
/// Handles the differing spellings used by terminals (`arrow_left`,
/// `page_up`, `digit_1`) and macOS virtual keycodes, collapsing them onto a
/// single vocabulary shared with jcode's own keybinding parser.
pub fn normalize_key(raw: &str) -> String {
let k = raw.trim().to_ascii_lowercase();
match k.as_str() {
// Arrows (ghostty/kitty style -> jcode style)
"arrow_left" | "left" => "left",
"arrow_right" | "right" => "right",
"arrow_up" | "up" => "up",
"arrow_down" | "down" => "down",
// Paging / navigation
"page_up" | "pageup" | "prior" => "pageup",
"page_down" | "pagedown" | "next" => "pagedown",
"home" => "home",
"end" => "end",
"insert" => "insert",
"delete" | "forward_delete" => "delete",
"backspace" => "backspace",
"return" | "enter" => "enter",
"escape" | "esc" => "esc",
"tab" => "tab",
"space" => "space",
// Named punctuation used by various terminals
"comma" => ",",
"period" => ".",
"slash" => "/",
"backslash" => "\\",
"semicolon" => ";",
"apostrophe" | "quote" => "'",
"grave" | "backtick" => "`",
"minus" => "-",
"equal" => "=",
"left_bracket" | "bracketleft" => "[",
"right_bracket" | "bracketright" => "]",
_ => {
// digit_N -> N
if let Some(d) = k.strip_prefix("digit_") {
return d.to_string();
}
// numpad_N -> N (best effort)
if let Some(d) = k.strip_prefix("numpad_") {
return d.to_string();
}
// Anything else (single chars, f1..f24, etc.) passes through.
return k;
}
}
.to_string()
}
}
fn pretty_key(key: &str) -> String {
match key {
"left" => "Left".to_string(),
"right" => "Right".to_string(),
"up" => "Up".to_string(),
"down" => "Down".to_string(),
"pageup" => "PageUp".to_string(),
"pagedown" => "PageDown".to_string(),
"home" => "Home".to_string(),
"end" => "End".to_string(),
"enter" => "Enter".to_string(),
"esc" => "Esc".to_string(),
"tab" => "Tab".to_string(),
"space" => "Space".to_string(),
"backspace" => "Backspace".to_string(),
"delete" => "Delete".to_string(),
other => {
if other.len() == 1 {
other.to_ascii_uppercase()
} else if let Some(rest) = other.strip_prefix('f') {
if rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty() {
return format!("F{rest}");
}
other.to_string()
} else {
other.to_string()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_orders_modifiers() {
let c = KeyChord::new(true, false, true, true, "K");
assert_eq!(c.canonical(), "cmd+alt+shift+k");
assert_eq!(c.display(), "Cmd+Alt+Shift+K");
}
#[test]
fn display_super_renders_characters_not_key_names() {
assert_eq!(KeyChord::parse("cmd+;").unwrap().display_super(), "Super+;");
assert_eq!(
KeyChord::parse("cmd+shift+'").unwrap().display_super(),
"Super+Shift+'"
);
assert_eq!(KeyChord::parse("cmd+[").unwrap().display_super(), "Super+[");
assert_eq!(
KeyChord::parse("cmd+\\").unwrap().display_super(),
"Super+\\"
);
}
#[test]
fn display_symbols_renders_modifier_glyphs() {
assert_eq!(KeyChord::parse("cmd+;").unwrap().display_symbols(), "⌘;");
assert_eq!(
KeyChord::parse("cmd+shift+'").unwrap().display_symbols(),
"⇧⌘'"
);
assert_eq!(
KeyChord::parse("ctrl+alt+k").unwrap().display_symbols(),
"⌃⌥K"
);
}
#[test]
fn normalizes_terminal_key_spellings() {
assert_eq!(KeyChord::normalize_key("arrow_left"), "left");
assert_eq!(KeyChord::normalize_key("page_up"), "pageup");
assert_eq!(KeyChord::normalize_key("digit_3"), "3");
assert_eq!(KeyChord::normalize_key("comma"), ",");
assert_eq!(KeyChord::normalize_key("F5"), "f5");
}
#[test]
fn equal_chords_compare_equal() {
let a = KeyChord::new(true, false, false, false, "k");
let b = KeyChord::new(true, false, false, false, "K");
assert_eq!(a, b);
assert_eq!(a.canonical(), b.canonical());
}
#[test]
fn parses_jcode_binding_strings() {
assert_eq!(KeyChord::parse("ctrl+k").unwrap().canonical(), "ctrl+k");
assert_eq!(
KeyChord::parse("alt+right").unwrap().canonical(),
"alt+right"
);
assert_eq!(
KeyChord::parse("ctrl+shift+tab").unwrap().canonical(),
"ctrl+shift+tab"
);
// Command/super alias both map to cmd.
assert_eq!(KeyChord::parse("cmd+j").unwrap().canonical(), "cmd+j");
assert_eq!(KeyChord::parse("super+j").unwrap().canonical(), "cmd+j");
// backtab implies shift+tab.
assert_eq!(KeyChord::parse("backtab").unwrap().canonical(), "shift+tab");
}
#[test]
fn parse_rejects_disabled_and_empty() {
assert!(KeyChord::parse("").is_none());
assert!(KeyChord::parse(" ").is_none());
assert!(KeyChord::parse("none").is_none());
assert!(KeyChord::parse("OFF").is_none());
assert!(KeyChord::parse("disabled").is_none());
}
#[test]
fn parse_matches_discovered_chord() {
// A jcode binding and a terminal binding for the same physical keys must
// compare equal so the conflict detector can pair them.
let jcode = KeyChord::parse("cmd+k").unwrap();
let terminal = KeyChord::new(true, false, false, false, "k");
assert_eq!(jcode, terminal);
}
}
@@ -0,0 +1,498 @@
//! Detect conflicts between jcode's own key bindings and the bindings
//! discovered on the machine (terminal emulator + macOS system shortcuts).
//!
//! The flow is:
//! 1. enumerate jcode's configured bindings as `(field, label, KeyChord)`
//! from [`jcode_config_types::KeybindingsConfig`] ([`jcode_bindings`]),
//! 2. index the discovered machine bindings from a [`KeymapSnapshot`],
//! 3. report every overlap as a [`Conflict`] that names the exact config
//! field so a warning can point the user at the right line.
//!
//! All of this is pure given a `KeybindingsConfig` and a `KeymapSnapshot`, so it
//! is fully unit-testable without touching the machine.
use std::collections::HashMap;
use jcode_config_types::KeybindingsConfig;
use super::KeymapSnapshot;
use super::chord::KeyChord;
use super::source::{DiscoveredBinding, KeySource};
/// One configured jcode binding, tied back to its config field.
#[derive(Debug, Clone)]
pub struct JcodeBinding {
/// The dotted config path, e.g. `keybindings.model_switch_next`.
pub field: String,
/// Human-friendly description of what the binding does.
pub action: String,
/// The configured value as written, e.g. `ctrl+tab`.
pub raw: String,
/// The parsed chord.
pub chord: KeyChord,
}
/// A detected conflict between a jcode binding and something on the machine.
#[derive(Debug, Clone)]
pub struct Conflict {
/// The jcode binding that may not reach the app.
pub jcode: JcodeBinding,
/// The machine binding that intercepts it.
pub interceptor: DiscoveredBinding,
}
impl Conflict {
/// A single-line, user-facing description of the conflict.
pub fn summary(&self) -> String {
format!(
"{} (`{}` = \"{}\") is also bound by your {} to {}",
self.jcode.action,
self.jcode.field,
self.jcode.raw,
self.interceptor_label(),
describe_action(&self.interceptor),
)
}
/// How to refer to the thing intercepting the key. For external apps this is
/// the specific tool name (e.g. "OmniWM"); otherwise the source label.
fn interceptor_label(&self) -> String {
match self.interceptor.source {
KeySource::ExternalApp if !self.interceptor.tool.is_empty() => {
self.interceptor.tool.clone()
}
other => other.label().to_string(),
}
}
}
fn describe_action(b: &DiscoveredBinding) -> String {
match b.source {
KeySource::MacosSystem => b.action.clone(),
KeySource::Terminal => {
if b.action.is_empty() {
"a terminal action".to_string()
} else {
format!("`{}`", b.action)
}
}
KeySource::ExternalApp => {
if b.action.is_empty() {
"an app action".to_string()
} else {
format!("`{}`", b.action)
}
}
}
}
/// Enumerate jcode's configured bindings as comparable chords. Bindings that are
/// disabled or fail to parse are skipped. Multi-binding fields (the workspace
/// navigation keys accept a comma-separated list) expand into one entry per
/// chord.
pub fn jcode_bindings(cfg: &KeybindingsConfig) -> Vec<JcodeBinding> {
// (field, action, raw-value) for single-chord fields.
let single: &[(&str, &str, &str)] = &[
("scroll_up", "Scroll up", cfg.scroll_up.as_str()),
("scroll_down", "Scroll down", cfg.scroll_down.as_str()),
("scroll_page_up", "Page up", cfg.scroll_page_up.as_str()),
(
"scroll_page_down",
"Page down",
cfg.scroll_page_down.as_str(),
),
(
"model_switch_next",
"Switch to next model",
cfg.model_switch_next.as_str(),
),
(
"model_switch_prev",
"Switch to previous model",
cfg.model_switch_prev.as_str(),
),
(
"effort_increase",
"Increase reasoning effort",
cfg.effort_increase.as_str(),
),
(
"effort_decrease",
"Decrease reasoning effort",
cfg.effort_decrease.as_str(),
),
(
"centered_toggle",
"Toggle centered layout",
cfg.centered_toggle.as_str(),
),
(
"scroll_prompt_up",
"Jump to previous prompt",
cfg.scroll_prompt_up.as_str(),
),
(
"scroll_prompt_down",
"Jump to next prompt",
cfg.scroll_prompt_down.as_str(),
),
(
"scroll_bookmark",
"Toggle scroll bookmark",
cfg.scroll_bookmark.as_str(),
),
(
"scroll_up_fallback",
"Scroll up (fallback)",
cfg.scroll_up_fallback.as_str(),
),
(
"scroll_down_fallback",
"Scroll down (fallback)",
cfg.scroll_down_fallback.as_str(),
),
(
"side_panel_toggle",
"Toggle side panel",
cfg.side_panel_toggle.as_str(),
),
(
"copy_selection_toggle",
"Toggle copy/selection mode",
cfg.copy_selection_toggle.as_str(),
),
(
"diagram_pane_toggle",
"Toggle diagram pane",
cfg.diagram_pane_toggle.as_str(),
),
(
"typing_scroll_lock_toggle",
"Toggle typing scroll lock",
cfg.typing_scroll_lock_toggle.as_str(),
),
(
"diff_mode_cycle",
"Cycle diff display mode",
cfg.diff_mode_cycle.as_str(),
),
(
"info_widget_toggle",
"Toggle info widget",
cfg.info_widget_toggle.as_str(),
),
(
"new_terminal",
"Spawn new terminal session",
cfg.new_terminal.as_str(),
),
];
let mut out = Vec::new();
for (field, action, raw) in single {
if let Some(chord) = KeyChord::parse(raw) {
out.push(JcodeBinding {
field: format!("keybindings.{field}"),
action: action.to_string(),
raw: raw.to_string(),
chord,
});
}
}
// Multi-binding (comma-separated list) fields.
let multi: [(&str, &str, &str); 4] = [
(
"workspace_left",
"Move to left workspace",
cfg.workspace_left.as_str(),
),
(
"workspace_down",
"Move to lower workspace",
cfg.workspace_down.as_str(),
),
(
"workspace_up",
"Move to upper workspace",
cfg.workspace_up.as_str(),
),
(
"workspace_right",
"Move to right workspace",
cfg.workspace_right.as_str(),
),
];
for (field, action, raw) in multi {
for piece in raw.split(',') {
let piece = piece.trim();
if let Some(chord) = KeyChord::parse(piece) {
out.push(JcodeBinding {
field: format!("keybindings.{field}"),
action: action.to_string(),
raw: piece.to_string(),
chord,
});
}
}
}
// Built-in prompt-navigation fallbacks. These are not configurable fields:
// `ScrollKeys::prompt_jump` always honors them in addition to the configured
// `scroll_prompt_up`/`scroll_prompt_down`. On macOS in particular, Cmd+K /
// Cmd+J move by prompt, which is exactly what a tiling window manager tends
// to steal for window focus, so we must enumerate them to detect that.
let fallbacks: &[(&str, &str)] = &[
("scroll_prompt_up", "cmd+k"),
("scroll_prompt_down", "cmd+j"),
("scroll_prompt_up", "cmd+["),
("scroll_prompt_down", "cmd+]"),
("scroll_prompt_up", "ctrl+["),
("scroll_prompt_down", "ctrl+]"),
];
for (field, raw) in fallbacks {
if let Some(chord) = KeyChord::parse(raw) {
// Skip if a configured single-chord field already produced this exact
// chord, to avoid duplicate conflict rows for the same keys.
if out.iter().any(|b| b.chord == chord) {
continue;
}
let action = if *field == "scroll_prompt_up" {
"Jump to previous prompt (built-in)"
} else {
"Jump to next prompt (built-in)"
};
out.push(JcodeBinding {
field: format!("keybindings.{field} (built-in fallback)"),
action: action.to_string(),
raw: (*raw).to_string(),
chord,
});
}
}
out
}
/// Find conflicts between jcode's configured bindings and the discovered
/// machine bindings in `snapshot`.
///
/// Conflicts are deduplicated per `(jcode field, interceptor chord, source)` so
/// a single overlap is reported once even if the snapshot lists the same chord
/// multiple times (Ghostty, for example, lists `super+1` and `super+digit_1`).
pub fn detect_conflicts(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Vec<Conflict> {
// Index discovered bindings by chord for O(1) lookup.
let mut by_chord: HashMap<&KeyChord, Vec<&DiscoveredBinding>> = HashMap::new();
for b in &snapshot.bindings {
by_chord.entry(&b.chord).or_default().push(b);
}
let mut seen: std::collections::HashSet<(String, String, KeySource)> =
std::collections::HashSet::new();
let mut conflicts = Vec::new();
for jcode in jcode_bindings(cfg) {
let Some(interceptors) = by_chord.get(&jcode.chord) else {
continue;
};
for interceptor in interceptors {
let dedup_key = (
jcode.field.clone(),
interceptor.chord.canonical(),
interceptor.source,
);
if !seen.insert(dedup_key) {
continue;
}
conflicts.push(Conflict {
jcode: jcode.clone(),
interceptor: (*interceptor).clone(),
});
}
}
conflicts
}
/// A stable signature for a set of conflicts, used to decide whether to re-warn
/// the user. Two runs that find the same conflicts (regardless of order)
/// produce the same signature; any change (new conflict, resolved conflict,
/// rebind) produces a different one.
pub fn conflict_signature(conflicts: &[Conflict]) -> String {
let mut parts: Vec<String> = conflicts
.iter()
.map(|c| {
format!(
"{}|{}|{}",
c.jcode.field,
c.jcode.chord.canonical(),
c.interceptor.chord.canonical()
)
})
.collect();
parts.sort();
parts.dedup();
parts.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymap::source::KeySource;
fn term_binding(canonical_keys: &str, action: &str) -> DiscoveredBinding {
DiscoveredBinding {
chord: KeyChord::parse(canonical_keys).unwrap(),
source: KeySource::Terminal,
action: action.to_string(),
raw: format!("{canonical_keys}={action}"),
tool: String::new(),
}
}
fn app_binding(canonical_keys: &str, action: &str, tool: &str) -> DiscoveredBinding {
DiscoveredBinding {
chord: KeyChord::parse(canonical_keys).unwrap(),
source: KeySource::ExternalApp,
action: action.to_string(),
raw: canonical_keys.to_string(),
tool: tool.to_string(),
}
}
fn snapshot_with(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
KeymapSnapshot {
version: 1,
captured_at: "0".to_string(),
os: "macos".to_string(),
terminal: "Ghostty".to_string(),
terminal_version: String::new(),
bindings,
}
}
#[test]
fn enumerates_default_bindings() {
let cfg = KeybindingsConfig::default();
let binds = jcode_bindings(&cfg);
// The defaults include model_switch_next = ctrl+tab.
assert!(
binds
.iter()
.any(|b| b.field == "keybindings.model_switch_next"
&& b.chord.canonical() == "ctrl+tab"),
"expected model_switch_next ctrl+tab in {binds:#?}"
);
// Workspace nav defaults are alt+h/j/k/l.
assert!(
binds
.iter()
.any(|b| b.field == "keybindings.workspace_left" && b.chord.canonical() == "alt+h")
);
}
#[test]
fn detects_ghostty_ctrl_tab_conflict() {
let cfg = KeybindingsConfig::default();
let snapshot = snapshot_with(vec![
term_binding("ctrl+tab", "next_tab"),
term_binding("ctrl+shift+tab", "previous_tab"),
]);
let conflicts = detect_conflicts(&cfg, &snapshot);
let fields: Vec<&str> = conflicts.iter().map(|c| c.jcode.field.as_str()).collect();
assert!(fields.contains(&"keybindings.model_switch_next"));
assert!(fields.contains(&"keybindings.model_switch_prev"));
let summary = conflicts[0].summary();
assert!(summary.contains("model"), "summary was: {summary}");
assert!(summary.contains("terminal"), "summary was: {summary}");
}
#[test]
fn detects_window_manager_prompt_jump_conflict() {
// The motivating case: a tiling WM (OmniWM) binds Cmd+J / Cmd+K to window
// focus, shadowing jcode's built-in prompt navigation fallback.
let cfg = KeybindingsConfig::default();
let snapshot = snapshot_with(vec![
app_binding("cmd+j", "focus.down", "OmniWM"),
app_binding("cmd+k", "focus.up", "OmniWM"),
]);
let conflicts = detect_conflicts(&cfg, &snapshot);
assert_eq!(conflicts.len(), 2, "both Cmd+J and Cmd+K should conflict");
// The interceptor is attributed to the specific tool, not a generic label.
let summary = conflicts[0].summary();
assert!(summary.contains("OmniWM"), "summary was: {summary}");
assert!(summary.contains("prompt"), "summary was: {summary}");
assert!(
conflicts
.iter()
.all(|c| c.jcode.field.contains("built-in fallback")),
"prompt-jump fallback fields should be flagged"
);
}
#[test]
fn no_conflict_when_chords_differ() {
let cfg = KeybindingsConfig::default();
let snapshot = snapshot_with(vec![term_binding("cmd+t", "new_tab")]);
// jcode has no cmd+t binding by default.
assert!(detect_conflicts(&cfg, &snapshot).is_empty());
}
#[test]
fn deduplicates_repeated_interceptor_chords() {
let cfg = KeybindingsConfig {
side_panel_toggle: "cmd+1".to_string(),
..Default::default()
};
// Ghostty lists both super+1 and super+digit_1 for goto_tab:1.
let snapshot = snapshot_with(vec![
term_binding("cmd+1", "goto_tab:1"),
term_binding("cmd+1", "goto_tab:1"),
]);
let conflicts = detect_conflicts(&cfg, &snapshot);
assert_eq!(conflicts.len(), 1, "duplicate interceptors should collapse");
}
#[test]
fn disabled_binding_is_not_reported() {
let cfg = KeybindingsConfig {
model_switch_next: "none".to_string(),
..Default::default()
};
let snapshot = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]);
let conflicts = detect_conflicts(&cfg, &snapshot);
assert!(
!conflicts
.iter()
.any(|c| c.jcode.field == "keybindings.model_switch_next")
);
}
#[test]
fn signature_is_order_independent_and_changes_on_diff() {
let cfg = KeybindingsConfig::default();
let snap_a = snapshot_with(vec![
term_binding("ctrl+tab", "next_tab"),
term_binding("ctrl+shift+tab", "previous_tab"),
]);
// Same conflicts, reversed discovery order.
let snap_b = snapshot_with(vec![
term_binding("ctrl+shift+tab", "previous_tab"),
term_binding("ctrl+tab", "next_tab"),
]);
let sig_a = conflict_signature(&detect_conflicts(&cfg, &snap_a));
let sig_b = conflict_signature(&detect_conflicts(&cfg, &snap_b));
assert_eq!(sig_a, sig_b, "signature must be order-independent");
let snap_c = snapshot_with(vec![term_binding("ctrl+tab", "next_tab")]);
let sig_c = conflict_signature(&detect_conflicts(&cfg, &snap_c));
assert_ne!(
sig_a, sig_c,
"different conflict set => different signature"
);
// No conflicts => empty signature.
let clean = snapshot_with(vec![term_binding("cmd+t", "new_tab")]);
assert_eq!(conflict_signature(&detect_conflicts(&cfg, &clean)), "");
}
}
@@ -0,0 +1,462 @@
//! Discover global key bindings declared by third-party apps that grab hotkeys
//! *before* the terminal (and therefore jcode) ever sees them.
//!
//! macOS lets window managers and automation tools register system-wide hotkeys.
//! When one of those overlaps a key jcode wants (the classic case is a tiling WM
//! binding `Cmd+J`/`Cmd+K` to window focus, which shadows jcode's prompt
//! navigation), the keystroke never reaches the terminal and the jcode binding
//! silently does nothing. The terminal/macOS scanners cannot see these, so we
//! read the relevant app config files directly.
//!
//! Each app has its own config grammar, so there is one pure parser per app
//! (`parse_*`) plus a thin reader (`read_*`) that locates and loads the file.
//! Parsers are unit-tested without touching the machine.
use std::path::{Path, PathBuf};
use super::chord::KeyChord;
use super::source::{DiscoveredBinding, KeySource};
/// A modifier mask accumulated while parsing a chord, kept separate from the key
/// token so apps that express the "hyper" key as a bundle of modifiers can be
/// expanded uniformly.
#[derive(Clone, Copy, Default)]
struct Mods {
cmd: bool,
ctrl: bool,
alt: bool,
shift: bool,
}
impl Mods {
fn or(self, other: Mods) -> Mods {
Mods {
cmd: self.cmd || other.cmd,
ctrl: self.ctrl || other.ctrl,
alt: self.alt || other.alt,
shift: self.shift || other.shift,
}
}
}
/// Apply a single modifier token to `mods`, expanding `hyper` via `hyper_mods`.
/// Returns `true` if the token was a recognized modifier, `false` if it should
/// be treated as the primary key.
fn apply_modifier(token: &str, mods: &mut Mods, hyper_mods: Mods) -> bool {
match token.trim().to_ascii_lowercase().as_str() {
"cmd" | "command" | "super" | "win" | "windows" => mods.cmd = true,
"ctrl" | "control" => mods.ctrl = true,
"alt" | "opt" | "option" => mods.alt = true,
"shift" => mods.shift = true,
// "hyper" is an app-defined alias for some bundle of real modifiers.
"hyper" => *mods = mods.or(hyper_mods),
// "fn" is not representable as a jcode modifier; ignore it so the rest of
// the chord still parses.
"fn" | "function" => {}
_ => return false,
}
true
}
/// Parse a "Hyper" definition (e.g. OmniWM's `hyperTrigger = "Option"`, or a
/// compound like "Cmd+Ctrl+Alt+Shift") into the modifier bundle it stands for.
fn parse_hyper_mods(spec: &str) -> Mods {
let mut mods = Mods::default();
for token in spec.split(['+', '-']) {
// Recurse-safe: "hyper" inside a hyper definition is meaningless, so pass
// an empty bundle.
apply_modifier(token, &mut mods, Mods::default());
}
mods
}
/// Build a chord from a list of tokens (modifiers + one key), where modifiers and
/// the key are already separated out. `hyper_mods` expands any `hyper` token.
/// Returns `None` if no primary key token was found.
fn chord_from_tokens<'a>(
tokens: impl IntoIterator<Item = &'a str>,
hyper_mods: Mods,
) -> Option<KeyChord> {
let mut mods = Mods::default();
let mut key: Option<String> = None;
for token in tokens {
let token = token.trim();
if token.is_empty() {
continue;
}
if !apply_modifier(token, &mut mods, hyper_mods) {
// Last non-modifier token wins as the key.
key = Some(token.to_string());
}
}
let key = key?;
Some(KeyChord::new(
mods.cmd, mods.ctrl, mods.alt, mods.shift, &key,
))
}
// ---------------------------------------------------------------------------
// OmniWM (~/.config/omniwm/settings.toml)
// ---------------------------------------------------------------------------
/// Parse an OmniWM `settings.toml` into discovered bindings. OmniWM stores
/// hotkeys as an array of tables:
///
/// ```toml
/// hyperTrigger = "Option"
///
/// [[hotkeys]]
/// binding = "Command+J"
/// id = "focus.down"
/// ```
///
/// `binding = "Unassigned"` entries are skipped. The `hyperTrigger` value (or a
/// default of Option) expands any `Hyper+...` binding.
pub fn parse_omniwm(text: &str) -> Vec<DiscoveredBinding> {
let Ok(value) = text.parse::<toml::Value>() else {
return Vec::new();
};
// Hyper expands to the configured trigger; OmniWM defaults to Option.
let hyper_mods = value
.get("general")
.and_then(|g| g.get("hyperTrigger"))
.or_else(|| value.get("hyperTrigger"))
.and_then(|h| h.as_str())
.map(parse_hyper_mods)
.unwrap_or(Mods {
alt: true,
..Mods::default()
});
let Some(hotkeys) = value.get("hotkeys").and_then(|h| h.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for hk in hotkeys {
let Some(binding) = hk.get("binding").and_then(|b| b.as_str()) else {
continue;
};
if binding.trim().eq_ignore_ascii_case("unassigned") || binding.trim().is_empty() {
continue;
}
let action = hk
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
if let Some(chord) = chord_from_tokens(binding.split(['+', '-']), hyper_mods) {
out.push(DiscoveredBinding {
chord,
source: KeySource::ExternalApp,
action,
raw: binding.to_string(),
tool: "OmniWM".to_string(),
});
}
}
out
}
/// Read and parse OmniWM's config, if present.
pub fn read_omniwm() -> Vec<DiscoveredBinding> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
let path = home.join(".config/omniwm/settings.toml");
read_to_string(&path)
.map(|t| parse_omniwm(&t))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// AeroSpace (~/.aerospace.toml or ~/.config/aerospace/aerospace.toml)
// ---------------------------------------------------------------------------
/// Parse an AeroSpace config into discovered bindings. AeroSpace declares
/// bindings under `[mode.<name>.binding]` tables where the key is a chord like
/// `alt-h` and the value is the command:
///
/// ```toml
/// [mode.main.binding]
/// alt-h = 'focus left'
/// cmd-shift-l = ['move right', 'mode main']
/// ```
pub fn parse_aerospace(text: &str) -> Vec<DiscoveredBinding> {
let Ok(value) = text.parse::<toml::Value>() else {
return Vec::new();
};
let Some(modes) = value.get("mode").and_then(|m| m.as_table()) else {
return Vec::new();
};
let mut out = Vec::new();
for mode in modes.values() {
let Some(bindings) = mode.get("binding").and_then(|b| b.as_table()) else {
continue;
};
for (chord_str, action_val) in bindings {
// AeroSpace separates modifiers and key with '-'.
let Some(chord) = chord_from_tokens(chord_str.split('-'), Mods::default()) else {
continue;
};
let action = aerospace_action_label(action_val);
out.push(DiscoveredBinding {
chord,
source: KeySource::ExternalApp,
action,
raw: chord_str.clone(),
tool: "AeroSpace".to_string(),
});
}
}
out
}
fn aerospace_action_label(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Array(items) => items
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("; "),
other => other.to_string(),
}
}
/// Read and parse AeroSpace's config from either supported location.
pub fn read_aerospace() -> Vec<DiscoveredBinding> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
for rel in [".aerospace.toml", ".config/aerospace/aerospace.toml"] {
let path = home.join(rel);
if let Some(text) = read_to_string(&path) {
return parse_aerospace(&text);
}
}
Vec::new()
}
// ---------------------------------------------------------------------------
// skhd (~/.config/skhd/skhdrc or ~/.skhdrc)
// ---------------------------------------------------------------------------
/// Parse an skhd config into discovered bindings. skhd lines look like:
///
/// ```text
/// cmd - h : yabai -m window --focus west
/// cmd + shift - 0x2C : echo hi
/// # comment
/// :: mode @ : ... # mode declaration, ignored
/// ```
///
/// The activation (left of the first `:`) is `mods - key`, where modifiers are
/// joined with `+` and separated from the key by `-`.
pub fn parse_skhd(text: &str) -> Vec<DiscoveredBinding> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("::") {
continue;
}
// Activation is everything before the first ':' that starts the command.
let Some(colon) = line.find(':') else {
continue;
};
let activation = line[..colon].trim();
let action = line[colon + 1..].trim();
if activation.is_empty() {
continue;
}
// Strip an optional leading mode list ("mode_name <") if present.
let activation = activation.rsplit('<').next().unwrap_or(activation).trim();
// Split modifiers from key on the first '-'. Modifiers use '+'.
let (mods_part, key_part) = match activation.split_once('-') {
Some((m, k)) => (m, k),
None => ("", activation),
};
let tokens = mods_part
.split('+')
.chain(std::iter::once(key_part))
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(chord) = chord_from_tokens(tokens, Mods::default()) {
out.push(DiscoveredBinding {
chord,
source: KeySource::ExternalApp,
action: action.to_string(),
raw: activation.to_string(),
tool: "skhd".to_string(),
});
}
}
out
}
/// Read and parse skhd's config from either supported location.
pub fn read_skhd() -> Vec<DiscoveredBinding> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
for rel in [".config/skhd/skhdrc", ".skhdrc"] {
let path = home.join(rel);
if let Some(text) = read_to_string(&path) {
return parse_skhd(&text);
}
}
Vec::new()
}
// ---------------------------------------------------------------------------
// Aggregation
// ---------------------------------------------------------------------------
/// Read every supported external app's bindings on this machine.
pub fn read_external_bindings() -> Vec<DiscoveredBinding> {
let mut out = Vec::new();
out.extend(read_omniwm());
out.extend(read_aerospace());
out.extend(read_skhd());
out
}
fn read_to_string(path: &Path) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// Exposed for tests/diagnostics: the config paths we look for, relative to the
/// home directory.
pub fn external_config_paths() -> Vec<PathBuf> {
[
".config/omniwm/settings.toml",
".aerospace.toml",
".config/aerospace/aerospace.toml",
".config/skhd/skhdrc",
".skhdrc",
]
.iter()
.map(PathBuf::from)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn omniwm_cmd_jk_focus_bindings() {
let cfg = r#"
[general]
hyperTrigger = "Option"
[[hotkeys]]
binding = "Command+J"
id = "focus.down"
[[hotkeys]]
binding = "Command+K"
id = "focus.up"
[[hotkeys]]
binding = "Unassigned"
id = "focusPrevious"
[[hotkeys]]
binding = "Hyper+1"
id = "switchWorkspace.0"
"#;
let binds = parse_omniwm(cfg);
// Cmd+J, Cmd+K, and Hyper(=Option=alt)+1; Unassigned is skipped.
assert_eq!(binds.len(), 3);
let jk: Vec<_> = binds
.iter()
.filter(|b| b.chord.canonical() == "cmd+j" || b.chord.canonical() == "cmd+k")
.collect();
assert_eq!(jk.len(), 2);
for b in &jk {
assert_eq!(b.source, KeySource::ExternalApp);
assert_eq!(b.tool, "OmniWM");
}
// Hyper+1 expands to alt+1 (Option trigger).
assert!(binds.iter().any(|b| b.chord.canonical() == "alt+1"));
}
#[test]
fn omniwm_hyper_defaults_to_option_when_unset() {
let cfg = r#"
[[hotkeys]]
binding = "Hyper+2"
id = "switchWorkspace.1"
"#;
let binds = parse_omniwm(cfg);
assert_eq!(binds.len(), 1);
assert_eq!(binds[0].chord.canonical(), "alt+2");
}
#[test]
fn omniwm_cmd_shift_move_bindings() {
let cfg = r#"
[[hotkeys]]
binding = "Command+Shift+K"
id = "move.up"
"#;
let binds = parse_omniwm(cfg);
assert_eq!(binds.len(), 1);
assert_eq!(binds[0].chord.canonical(), "cmd+shift+k");
assert_eq!(binds[0].action, "move.up");
}
#[test]
fn aerospace_binding_section() {
let cfg = r#"
[mode.main.binding]
alt-h = 'focus left'
cmd-shift-l = ['move right', 'mode main']
"#;
let binds = parse_aerospace(cfg);
assert_eq!(binds.len(), 2);
let h = binds
.iter()
.find(|b| b.chord.canonical() == "alt+h")
.unwrap();
assert_eq!(h.action, "focus left");
assert_eq!(h.tool, "AeroSpace");
let l = binds
.iter()
.find(|b| b.chord.canonical() == "cmd+shift+l")
.unwrap();
assert_eq!(l.action, "move right; mode main");
}
#[test]
fn skhd_basic_lines() {
let cfg = "\
# comment\n\
cmd - h : yabai -m window --focus west\n\
cmd + shift - j : yabai -m window --swap south\n\
:: default : echo mode\n\
";
let binds = parse_skhd(cfg);
assert_eq!(binds.len(), 2);
assert_eq!(binds[0].chord.canonical(), "cmd+h");
assert_eq!(binds[0].tool, "skhd");
assert_eq!(binds[1].chord.canonical(), "cmd+shift+j");
}
#[test]
fn skhd_keyless_modifier_only_is_skipped() {
// A bare modifier with no key cannot form a chord.
let binds = parse_skhd("cmd - : noop\n");
assert!(binds.is_empty());
}
#[test]
fn malformed_toml_yields_nothing() {
assert!(parse_omniwm("this is = = not toml").is_empty());
assert!(parse_aerospace("[[[bad").is_empty());
}
}
@@ -0,0 +1,349 @@
//! Decode macOS system keyboard shortcuts from `com.apple.symbolichotkeys`.
//!
//! macOS stores global shortcuts (Spotlight, Mission Control, screenshots,
//! input-source switching, etc.) under the `AppleSymbolicHotKeys` key of the
//! `com.apple.symbolichotkeys` preference domain. Each entry is:
//!
//! ```text
//! <id> = { enabled = 0/1; value = { parameters = (ascii, keycode, modmask); ... }; }
//! ```
//!
//! We read it with `defaults export ... | plutil -convert json` and decode the
//! `[ascii, keycode, modmask]` triple into a [`KeyChord`]. The pure decoding
//! logic lives here (and is unit-tested); the subprocess plumbing is isolated in
//! [`read_symbolic_hotkeys`].
use super::chord::KeyChord;
use super::source::{DiscoveredBinding, KeySource};
/// NSEvent modifier flag bits used in the symbolic-hotkeys `modmask`.
const NS_SHIFT: u64 = 0x0002_0000;
const NS_CONTROL: u64 = 0x0004_0000;
const NS_OPTION: u64 = 0x0008_0000;
const NS_COMMAND: u64 = 0x0010_0000;
/// Human-readable names for well-known symbolic-hotkey IDs. Only used to make
/// the snapshot and warnings legible; unknown IDs still get decoded.
fn action_name(id: i64) -> String {
let name = match id {
32 => "Mission Control",
33 => "Mission Control: Application windows",
36 => "Show Launchpad",
60 => "Select previous input source",
61 => "Select next input source",
64 => "Spotlight: Show search",
65 => "Spotlight: Show Finder search window",
79 => "Move left a space",
81 => "Move right a space",
// Screenshots
28 => "Screenshot: Save picture of screen",
29 => "Screenshot: Copy picture of screen",
30 => "Screenshot: Save picture of selected area",
31 => "Screenshot: Copy picture of selected area",
184 => "Screenshot and recording options",
// Misc
162 => "Show Notification Center",
163 => "Toggle Do Not Disturb",
175 => "Dictation",
_ => "",
};
if name.is_empty() {
format!("macOS hotkey #{id}")
} else {
name.to_string()
}
}
/// Map a macOS virtual keycode to a normalized key token. Covers the common
/// keys that appear in default system shortcuts. Returns `None` for keys we do
/// not have a stable mapping for (we then fall back to the ASCII parameter).
fn keycode_to_token(keycode: i64) -> Option<&'static str> {
Some(match keycode {
0 => "a",
1 => "s",
2 => "d",
3 => "f",
4 => "h",
5 => "g",
6 => "z",
7 => "x",
8 => "c",
9 => "v",
11 => "b",
12 => "q",
13 => "w",
14 => "e",
15 => "r",
16 => "y",
17 => "t",
18 => "1",
19 => "2",
20 => "3",
21 => "4",
22 => "6",
23 => "5",
24 => "=",
25 => "9",
26 => "7",
27 => "-",
28 => "8",
29 => "0",
30 => "]",
31 => "o",
32 => "u",
33 => "[",
34 => "i",
35 => "p",
37 => "l",
38 => "j",
39 => "'",
40 => "k",
41 => ";",
42 => "\\",
43 => ",",
44 => "/",
45 => "n",
46 => "m",
47 => ".",
50 => "`",
36 => "enter",
48 => "tab",
49 => "space",
51 => "backspace",
53 => "esc",
// Function keys
122 => "f1",
120 => "f2",
99 => "f3",
118 => "f4",
96 => "f5",
97 => "f6",
98 => "f7",
100 => "f8",
101 => "f9",
109 => "f10",
103 => "f11",
111 => "f12",
// Arrows
123 => "left",
124 => "right",
125 => "down",
126 => "up",
_ => return None,
})
}
/// Decode a single `[ascii, keycode, modmask]` parameter triple into a chord.
///
/// `ascii == 65535` (and `keycode == 65535`) means "no key assigned", in which
/// case we return `None`. Prefer the virtual keycode for the key token; fall
/// back to the ASCII value when the keycode is unknown.
pub fn decode_parameters(ascii: i64, keycode: i64, modmask: i64) -> Option<KeyChord> {
if keycode == 65535 && ascii == 65535 {
return None;
}
let mask = modmask as u64;
let cmd = mask & NS_COMMAND != 0;
let ctrl = mask & NS_CONTROL != 0;
let alt = mask & NS_OPTION != 0;
let shift = mask & NS_SHIFT != 0;
let key = if let Some(tok) = keycode_to_token(keycode) {
tok.to_string()
} else if ascii > 0 && ascii < 0x10_FFFF && ascii != 65535 {
// Fall back to the literal character from the ascii parameter.
char::from_u32(ascii as u32)
.filter(|c| !c.is_control())
.map(|c| c.to_ascii_lowercase().to_string())?
} else {
return None;
};
Some(KeyChord::new(cmd, ctrl, alt, shift, &key))
}
/// One raw symbolic-hotkey entry, as decoded from JSON.
#[derive(Debug, Clone)]
pub struct RawHotkey {
pub id: i64,
pub enabled: bool,
pub parameters: Option<(i64, i64, i64)>,
}
/// Parse the JSON produced by `plutil -convert json` of the symbolic-hotkeys
/// domain into raw hotkey entries. Tolerates the parameters being encoded as
/// either JSON numbers or numeric strings (macOS does both).
pub fn parse_symbolic_hotkeys_json(json: &str) -> Vec<RawHotkey> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
let Some(map) = value
.get("AppleSymbolicHotKeys")
.and_then(|v| v.as_object())
else {
return Vec::new();
};
let mut out = Vec::new();
for (id_str, entry) in map {
let Ok(id) = id_str.parse::<i64>() else {
continue;
};
let enabled = match entry.get("enabled") {
Some(serde_json::Value::Bool(b)) => *b,
Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0) != 0,
_ => false,
};
let parameters = entry
.get("value")
.and_then(|v| v.get("parameters"))
.and_then(|p| p.as_array())
.and_then(|arr| {
let nums: Vec<i64> = arr.iter().filter_map(json_as_i64).collect();
if nums.len() >= 3 {
Some((nums[0], nums[1], nums[2]))
} else {
None
}
});
out.push(RawHotkey {
id,
enabled,
parameters,
});
}
out
}
fn json_as_i64(v: &serde_json::Value) -> Option<i64> {
match v {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => s.trim().parse::<i64>().ok(),
_ => None,
}
}
/// Turn raw hotkey entries into discovered bindings, skipping ones that are
/// disabled or have no assignable key.
pub fn hotkeys_to_bindings(raw: &[RawHotkey]) -> Vec<DiscoveredBinding> {
let mut out = Vec::new();
for hk in raw {
if !hk.enabled {
continue;
}
let Some((ascii, keycode, modmask)) = hk.parameters else {
continue;
};
let Some(chord) = decode_parameters(ascii, keycode, modmask) else {
continue;
};
out.push(DiscoveredBinding {
chord,
source: KeySource::MacosSystem,
action: action_name(hk.id),
raw: format!("symbolichotkey #{}", hk.id),
tool: String::new(),
});
}
out
}
/// Read and decode the live macOS symbolic hotkeys via `defaults` + `plutil`.
/// Returns an empty vec on any failure (missing tools, non-macOS, parse error).
#[cfg(target_os = "macos")]
pub fn read_symbolic_hotkeys() -> Vec<DiscoveredBinding> {
use std::process::Command;
let export = Command::new("/usr/bin/defaults")
.args(["export", "com.apple.symbolichotkeys", "-"])
.output();
let Ok(export) = export else {
return Vec::new();
};
if !export.status.success() || export.stdout.is_empty() {
return Vec::new();
}
// Pipe the exported plist through plutil to get JSON.
let mut child = match Command::new("/usr/bin/plutil")
.args(["-convert", "json", "-o", "-", "-"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => return Vec::new(),
};
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(&export.stdout);
// stdin dropped here, closing the pipe.
}
let Ok(output) = child.wait_with_output() else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let json = String::from_utf8_lossy(&output.stdout);
let raw = parse_symbolic_hotkeys_json(&json);
hotkeys_to_bindings(&raw)
}
#[cfg(not(target_os = "macos"))]
pub fn read_symbolic_hotkeys() -> Vec<DiscoveredBinding> {
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_spotlight_cmd_space() {
// [32 (space ascii), 49 (space keycode), 0x100000 (cmd)]
let chord = decode_parameters(32, 49, 1_048_576).unwrap();
assert_eq!(chord.canonical(), "cmd+space");
}
#[test]
fn decodes_input_source_ctrl_space() {
// Select previous input source: [32, 49, 0x40000 (ctrl)]
let chord = decode_parameters(32, 49, 262_144).unwrap();
assert_eq!(chord.canonical(), "ctrl+space");
}
#[test]
fn unassigned_returns_none() {
assert!(decode_parameters(65535, 65535, 0).is_none());
}
#[test]
fn combined_modifiers_decode() {
// ctrl+option+cmd = 0x40000 | 0x80000 | 0x100000 = 0x1C0000
let chord = decode_parameters(0, 40, 0x1C_0000).unwrap();
assert!(chord.cmd && chord.ctrl && chord.alt && !chord.shift);
assert_eq!(chord.key, "k");
}
#[test]
fn parses_json_with_string_and_numeric_params() {
let json = r#"{
"AppleSymbolicHotKeys": {
"64": {"enabled": 1, "value": {"parameters": ["32", "49", "1048576"]}},
"60": {"enabled": false, "value": {"parameters": [32, 49, 262144]}},
"999": {"enabled": 1}
}
}"#;
let raw = parse_symbolic_hotkeys_json(json);
assert_eq!(raw.len(), 3);
let bindings = hotkeys_to_bindings(&raw);
// Only #64 is enabled with params.
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].chord.canonical(), "cmd+space");
assert!(bindings[0].action.contains("Spotlight"));
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Keymap discovery: snapshot the key bindings that exist on the machine
//! (macOS system shortcuts + terminal emulator bindings) so jcode can detect
//! when one of them intercepts a key jcode wants to use.
//!
//! This module is the data layer. It:
//! 1. discovers bindings from each source ([`macos_hotkeys`], [`terminal`]),
//! 2. normalizes them to [`KeyChord`]s ([`chord`]),
//! 3. records them in a durable JSON snapshot ([`KeymapSnapshot`]).
//!
//! Conflict detection against jcode's own bindings is built on top of this in a
//! later layer.
pub mod chord;
pub mod conflicts;
pub mod external;
pub mod macos_hotkeys;
pub mod report;
pub mod source;
pub mod terminal;
pub use chord::KeyChord;
pub use conflicts::{Conflict, JcodeBinding, conflict_signature, detect_conflicts, jcode_bindings};
pub use report::{render_report, render_status_line};
pub use source::{DiscoveredBinding, KeySource};
use serde::{Deserialize, Serialize};
/// Schema version for the on-disk snapshot. Bump when the format changes.
const SNAPSHOT_VERSION: u32 = 1;
/// A durable record of the key bindings discovered on this machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeymapSnapshot {
pub version: u32,
/// RFC3339-ish timestamp of when the snapshot was taken.
pub captured_at: String,
pub os: String,
/// Detected terminal label (e.g. "Ghostty"), best-effort.
pub terminal: String,
/// Terminal version string if known.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub terminal_version: String,
/// All discovered bindings, across every source.
pub bindings: Vec<DiscoveredBinding>,
}
impl KeymapSnapshot {
/// Bindings originating from a particular source.
pub fn from_source(&self, source: KeySource) -> impl Iterator<Item = &DiscoveredBinding> {
self.bindings.iter().filter(move |b| b.source == source)
}
}
/// Detect the terminal name in a cross-platform, dependency-light way. On macOS
/// we mirror the detection used elsewhere; on other platforms we fall back to
/// `TERM_PROGRAM`/`TERM`.
fn detect_terminal_label() -> String {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
let term = std::env::var("TERM").unwrap_or_default();
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|| term_program.eq_ignore_ascii_case("ghostty")
|| term.to_lowercase().contains("ghostty")
{
return "Ghostty".to_string();
}
match term_program.to_lowercase().as_str() {
"iterm.app" => "iTerm2".to_string(),
"apple_terminal" => "Terminal.app".to_string(),
"wezterm" => "WezTerm".to_string(),
"vscode" => "VS Code terminal".to_string(),
"" => {
if term.to_lowercase().contains("alacritty") {
"Alacritty".to_string()
} else if term.to_lowercase().contains("kitty") {
"kitty".to_string()
} else {
term
}
}
other => other.to_string(),
}
}
fn now_timestamp() -> String {
// Avoid pulling in chrono here; seconds since epoch is enough to detect a
// stale snapshot, and we render it back as a readable value at display time.
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_default()
}
/// Collect all discovered bindings on the current machine. This shells out to
/// the platform tools (`defaults`/`plutil`, `ghostty +list-keybinds`) and so
/// should be called off the hot path / at startup, not per-frame.
pub fn collect_snapshot() -> KeymapSnapshot {
let mut bindings = Vec::new();
bindings.extend(macos_hotkeys::read_symbolic_hotkeys());
bindings.extend(terminal::read_ghostty_keybinds());
bindings.extend(external::read_external_bindings());
KeymapSnapshot {
version: SNAPSHOT_VERSION,
captured_at: now_timestamp(),
os: std::env::consts::OS.to_string(),
terminal: detect_terminal_label(),
terminal_version: std::env::var("TERM_PROGRAM_VERSION").unwrap_or_default(),
bindings,
}
}
/// Path of the on-disk keymap snapshot.
pub fn snapshot_path() -> anyhow::Result<std::path::PathBuf> {
Ok(jcode_storage::jcode_dir()?.join("keymap-snapshot.json"))
}
/// Collect a fresh snapshot and persist it to `~/.jcode/keymap-snapshot.json`.
/// Returns the snapshot regardless of whether the write succeeded.
pub fn refresh_and_save() -> KeymapSnapshot {
let snapshot = collect_snapshot();
if let Ok(path) = snapshot_path()
// Regeneratable cache (refreshed at least daily): a power-loss losing the
// newest copy just means one more refresh on the next launch, so skip the
// ~8ms macOS `F_FULLFSYNC` and use the atomic-rename fast write.
&& let Err(err) = jcode_storage::write_json_fast(&path, &snapshot)
{
jcode_logging::warn(&format!("keymap snapshot write failed: {err}"));
}
snapshot
}
/// Load the last persisted snapshot, if any.
pub fn load_snapshot() -> Option<KeymapSnapshot> {
let path = snapshot_path().ok()?;
jcode_storage::read_json(&path).ok()
}
/// Maximum age (seconds) before a cached snapshot is considered stale and
/// refreshed. One day balances freshness against the cost of shelling out.
const SNAPSHOT_MAX_AGE_SECS: u64 = 24 * 60 * 60;
/// Return a usable snapshot, refreshing from the machine only when there is no
/// cached snapshot or the cached one is older than [`SNAPSHOT_MAX_AGE_SECS`].
/// This is the entry point intended for startup: cheap on the common path,
/// self-healing when stale.
pub fn snapshot_cached_or_refresh() -> KeymapSnapshot {
if let Some(existing) = load_snapshot()
&& existing.version == SNAPSHOT_VERSION
&& !snapshot_is_stale(&existing)
{
return existing;
}
refresh_and_save()
}
fn snapshot_is_stale(snapshot: &KeymapSnapshot) -> bool {
use std::time::{SystemTime, UNIX_EPOCH};
let Ok(captured) = snapshot.captured_at.parse::<u64>() else {
return true;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(captured) > SNAPSHOT_MAX_AGE_SECS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_roundtrips_through_json() {
let snap = KeymapSnapshot {
version: SNAPSHOT_VERSION,
captured_at: "123".to_string(),
os: "macos".to_string(),
terminal: "Ghostty".to_string(),
terminal_version: "1.3.1".to_string(),
bindings: vec![DiscoveredBinding {
chord: KeyChord::new(true, false, false, false, "k"),
source: KeySource::Terminal,
action: "clear_screen".to_string(),
raw: "super+k=clear_screen".to_string(),
tool: String::new(),
}],
};
let json = serde_json::to_string(&snap).unwrap();
let back: KeymapSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(back.bindings.len(), 1);
assert_eq!(back.bindings[0].chord.canonical(), "cmd+k");
assert_eq!(
back.from_source(KeySource::Terminal).count(),
1,
"should find the terminal binding"
);
}
}
@@ -0,0 +1,190 @@
//! Human-readable rendering of the keymap snapshot and detected conflicts,
//! shared by the `/keys` command and the startup conflict hint.
use jcode_config_types::KeybindingsConfig;
use super::KeymapSnapshot;
use super::conflicts::{Conflict, detect_conflicts};
use super::source::KeySource;
/// Render a full diagnostic report: detected terminal, discovered binding
/// counts, and any conflicts with jcode's configured bindings.
pub fn render_report(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> String {
let mut out = String::new();
out.push_str("Keymap diagnostics\n");
out.push_str(&format!(
"Terminal: {}{}\n",
snapshot.terminal,
if snapshot.terminal_version.is_empty() {
String::new()
} else {
format!(" {}", snapshot.terminal_version)
}
));
out.push_str(&format!("OS: {}\n", snapshot.os));
let term_count = snapshot.from_source(KeySource::Terminal).count();
let sys_count = snapshot.from_source(KeySource::MacosSystem).count();
let app_count = snapshot.from_source(KeySource::ExternalApp).count();
out.push_str(&format!(
"Discovered bindings: {term_count} terminal, {sys_count} macOS system, {app_count} app\n",
));
if app_count > 0 {
let mut tools: Vec<&str> = snapshot
.from_source(KeySource::ExternalApp)
.map(|b| b.tool.as_str())
.filter(|t| !t.is_empty())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
tools.dedup();
if !tools.is_empty() {
out.push_str(&format!("Apps scanned: {}\n", tools.join(", ")));
}
}
if term_count == 0 && sys_count == 0 && app_count == 0 {
out.push_str(
"\nNo machine bindings were discovered. jcode can read Ghostty bindings, macOS\n\
system shortcuts, and a few window managers (OmniWM, AeroSpace, skhd); other\n\
terminals and tools are not yet inspected, so conflicts there will not be\n\
detected.\n",
);
}
let conflicts = detect_conflicts(cfg, snapshot);
out.push('\n');
if conflicts.is_empty() {
out.push_str("No conflicts found between your jcode keybindings and the machine.\n");
} else {
out.push_str(&format!(
"{} potential conflict{} found:\n\n",
conflicts.len(),
if conflicts.len() == 1 { "" } else { "s" }
));
for c in &conflicts {
out.push_str(&render_conflict_block(c));
out.push('\n');
}
out.push_str(
"These keys may be captured by your terminal, macOS, or another app (window\n\
manager, launcher) before jcode sees them.\n\
To fix: rebind the jcode action in ~/.jcode/config.toml under [keybindings],\n\
or change the conflicting shortcut in the other app's settings.\n",
);
}
out
}
fn render_conflict_block(c: &Conflict) -> String {
let interceptor_desc = match c.interceptor.source {
KeySource::MacosSystem => format!("macOS: {}", c.interceptor.action),
KeySource::Terminal => {
if c.interceptor.action.is_empty() {
"terminal action".to_string()
} else {
format!("terminal: {}", c.interceptor.action)
}
}
KeySource::ExternalApp => {
let tool = if c.interceptor.tool.is_empty() {
"app"
} else {
c.interceptor.tool.as_str()
};
if c.interceptor.action.is_empty() {
tool.to_string()
} else {
format!("{tool}: {}", c.interceptor.action)
}
}
};
format!(
"{key}\n jcode: {action} ({field} = \"{raw}\")\n taken by {interceptor}\n",
key = c.jcode.chord.display(),
action = c.jcode.action,
field = c.jcode.field,
raw = c.jcode.raw,
interceptor = interceptor_desc,
)
}
/// A compact one-line status string suitable for a startup notice, or `None`
/// when there are no conflicts.
pub fn render_status_line(cfg: &KeybindingsConfig, snapshot: &KeymapSnapshot) -> Option<String> {
let conflicts = detect_conflicts(cfg, snapshot);
if conflicts.is_empty() {
return None;
}
let keys: Vec<String> = conflicts
.iter()
.map(|c| c.jcode.chord.display())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
Some(format!(
"Keybinding conflict: {} may be intercepted by your terminal/OS/apps. Run /keys for details.",
keys.join(", ")
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keymap::KeyChord;
use crate::keymap::source::DiscoveredBinding;
fn snapshot_with(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
KeymapSnapshot {
version: 1,
captured_at: "0".to_string(),
os: "macos".to_string(),
terminal: "Ghostty".to_string(),
terminal_version: "1.3.1".to_string(),
bindings,
}
}
fn term(keys: &str, action: &str) -> DiscoveredBinding {
DiscoveredBinding {
chord: KeyChord::parse(keys).unwrap(),
source: KeySource::Terminal,
action: action.to_string(),
raw: format!("{keys}={action}"),
tool: String::new(),
}
}
#[test]
fn report_lists_conflicts_with_field_names() {
let cfg = KeybindingsConfig::default();
let snap = snapshot_with(vec![term("ctrl+tab", "next_tab")]);
let report = render_report(&cfg, &snap);
assert!(report.contains("Ghostty 1.3.1"));
assert!(report.contains("keybindings.model_switch_next"));
assert!(report.contains("next_tab"));
assert!(report.contains("Ctrl+Tab"));
}
#[test]
fn report_says_clean_when_no_conflicts() {
let cfg = KeybindingsConfig::default();
let snap = snapshot_with(vec![term("cmd+t", "new_tab")]);
let report = render_report(&cfg, &snap);
assert!(report.contains("No conflicts found"));
}
#[test]
fn status_line_present_only_on_conflict() {
let cfg = KeybindingsConfig::default();
let clean = snapshot_with(vec![term("cmd+t", "new_tab")]);
assert!(render_status_line(&cfg, &clean).is_none());
let dirty = snapshot_with(vec![term("ctrl+tab", "next_tab")]);
let line = render_status_line(&cfg, &dirty).unwrap();
assert!(line.contains("Ctrl+Tab"));
assert!(line.contains("/keys"));
}
}
@@ -0,0 +1,51 @@
//! Where a discovered key binding came from, and the binding record itself.
use serde::{Deserialize, Serialize};
use super::chord::KeyChord;
/// The origin of a discovered binding on the machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeySource {
/// A macOS system-wide shortcut (`com.apple.symbolichotkeys`).
MacosSystem,
/// A binding declared by the terminal emulator (config or built-in default).
Terminal,
/// A binding declared by a third-party app that grabs global hotkeys before
/// the terminal sees them: window managers (OmniWM, AeroSpace, yabai/skhd),
/// automation tools (Hammerspoon), launchers (Raycast), etc. The specific
/// app is named in [`DiscoveredBinding::tool`].
ExternalApp,
}
impl KeySource {
pub fn label(self) -> &'static str {
match self {
KeySource::MacosSystem => "macOS system shortcut",
KeySource::Terminal => "terminal",
KeySource::ExternalApp => "external app",
}
}
}
/// A key binding discovered on the machine that may intercept input before it
/// reaches jcode.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredBinding {
/// The normalized chord this binding triggers on.
pub chord: KeyChord,
/// Which layer owns this binding.
pub source: KeySource,
/// What the binding does, e.g. "Spotlight: Show search" or
/// "copy_to_clipboard:mixed".
pub action: String,
/// The raw declaration we parsed, for debugging (e.g. the original config
/// line or the symbolic-hotkey id).
pub raw: String,
/// For [`KeySource::ExternalApp`], the human-facing name of the app that
/// owns this binding (e.g. "OmniWM", "AeroSpace", "skhd"). Empty for the
/// macOS system and terminal sources, where the source label is enough.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tool: String,
}
@@ -0,0 +1,233 @@
//! Discover key bindings declared by terminal emulators.
//!
//! Different terminals store bindings in different ways. The most reliable
//! approach for Ghostty is to ask it for its *effective* binding set via
//! `ghostty +list-keybinds`, which merges built-in defaults with the user's
//! config. The parsing is pure and unit-tested; only [`read_ghostty_keybinds`]
//! shells out.
use super::chord::KeyChord;
use super::source::{DiscoveredBinding, KeySource};
/// Parse a single Ghostty keybind line of the form:
///
/// ```text
/// keybind = super+shift+,=reload_config
/// super+backspace=text:\x17
/// ```
///
/// The left side (up to the first top-level `=`) is the trigger; the right side
/// is the action. The trigger is `mod+mod+key`. Returns `None` for lines that
/// are not bindings (comments, blanks, multi-key sequences we do not model).
pub fn parse_ghostty_keybind_line(line: &str) -> Option<DiscoveredBinding> {
let mut line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
// Strip an optional leading `keybind =` / `keybind:` prefix.
if let Some(rest) = line.strip_prefix("keybind") {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':'))?;
line = rest.trim();
}
// Split trigger=action on the first '='.
let eq = line.find('=')?;
let trigger = line[..eq].trim();
let action = line[eq + 1..].trim();
if trigger.is_empty() {
return None;
}
let chord = parse_trigger(trigger)?;
Some(DiscoveredBinding {
chord,
source: KeySource::Terminal,
action: action.to_string(),
raw: line.to_string(),
tool: String::new(),
})
}
/// Parse a `mod+mod+key` trigger into a chord. Returns `None` for triggers that
/// describe a multi-key sequence (Ghostty uses `>` between chords) since we only
/// model single chords for conflict detection.
fn parse_trigger(trigger: &str) -> Option<KeyChord> {
if trigger.contains('>') {
return None;
}
// Ghostty exposes a few logical triggers (mapped to the platform's native
// shortcut) that are not real key chords. They can never collide with a
// jcode binding, so drop them to keep the snapshot clean.
if matches!(
trigger.to_ascii_lowercase().as_str(),
"copy" | "paste" | "unbind" | "ignore"
) {
return None;
}
let mut cmd = false;
let mut ctrl = false;
let mut alt = false;
let mut shift = false;
let mut key: Option<String> = None;
// Split on '+', but a trailing '+' means the key itself is '+'.
let tokens = split_trigger_tokens(trigger);
for tok in tokens {
match tok.to_ascii_lowercase().as_str() {
"super" | "cmd" | "command" => cmd = true,
"ctrl" | "control" => ctrl = true,
"alt" | "opt" | "option" => alt = true,
"shift" => shift = true,
other => {
// Last non-modifier token wins as the key.
key = Some(other.to_string());
}
}
}
let key = key?;
Some(KeyChord::new(cmd, ctrl, alt, shift, &key))
}
/// Split a trigger on '+' while treating a literal '+' key correctly. For
/// example `super++` is `["super", "+"]` and `ctrl+shift++` is
/// `["ctrl", "shift", "+"]`.
fn split_trigger_tokens(trigger: &str) -> Vec<String> {
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let chars: Vec<char> = trigger.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '+' {
if cur.is_empty() {
// A '+' with nothing before it is the literal '+' key.
// Only treat it as the key when it is not a separator between
// two names (i.e. previous char was already a separator).
let is_trailing_or_double = i + 1 == chars.len() || chars[i + 1] == '+';
if is_trailing_or_double || tokens.is_empty() {
tokens.push("+".to_string());
continue;
}
}
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
}
/// Parse the full output of `ghostty +list-keybinds` (or a Ghostty config file)
/// into discovered bindings.
pub fn parse_ghostty_keybinds(output: &str) -> Vec<DiscoveredBinding> {
output
.lines()
.filter_map(parse_ghostty_keybind_line)
.collect()
}
/// Run `ghostty +list-keybinds` and parse its output. Returns an empty vec on
/// any failure. Tries the bundled macOS binary first, then `ghostty` on PATH.
#[cfg(target_os = "macos")]
pub fn read_ghostty_keybinds() -> Vec<DiscoveredBinding> {
use std::process::Command;
const CANDIDATES: [&str; 2] = [
"/Applications/Ghostty.app/Contents/MacOS/ghostty",
"ghostty",
];
for bin in CANDIDATES {
let Ok(output) = Command::new(bin).arg("+list-keybinds").output() else {
continue;
};
if output.status.success() && !output.stdout.is_empty() {
let text = String::from_utf8_lossy(&output.stdout);
return parse_ghostty_keybinds(&text);
}
}
Vec::new()
}
#[cfg(not(target_os = "macos"))]
pub fn read_ghostty_keybinds() -> Vec<DiscoveredBinding> {
use std::process::Command;
let Ok(output) = Command::new("ghostty").arg("+list-keybinds").output() else {
return Vec::new();
};
if output.status.success() && !output.stdout.is_empty() {
let text = String::from_utf8_lossy(&output.stdout);
return parse_ghostty_keybinds(&text);
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_listed_keybind() {
let b = parse_ghostty_keybind_line("keybind = super+c=copy_to_clipboard:mixed").unwrap();
assert_eq!(b.chord.canonical(), "cmd+c");
assert_eq!(b.action, "copy_to_clipboard:mixed");
}
#[test]
fn parses_bare_config_line() {
let b = parse_ghostty_keybind_line("super+backspace=text:\\x17").unwrap();
assert_eq!(b.chord.canonical(), "cmd+backspace");
assert_eq!(b.action, "text:\\x17");
}
#[test]
fn parses_named_punctuation_key() {
let b = parse_ghostty_keybind_line("keybind = super+shift+,=reload_config").unwrap();
assert_eq!(b.chord.canonical(), "cmd+shift+,");
}
#[test]
fn parses_digit_key() {
let b = parse_ghostty_keybind_line("keybind = super+digit_1=goto_tab:1").unwrap();
assert_eq!(b.chord.canonical(), "cmd+1");
}
#[test]
fn parses_literal_plus_key() {
let b = parse_ghostty_keybind_line("keybind = super++=increase_font_size:1").unwrap();
assert_eq!(b.chord.canonical(), "cmd++");
}
#[test]
fn skips_comments_and_blanks() {
assert!(parse_ghostty_keybind_line("# a comment").is_none());
assert!(parse_ghostty_keybind_line(" ").is_none());
}
#[test]
fn skips_multi_key_sequences() {
assert!(parse_ghostty_keybind_line("keybind = ctrl+a>n=new_window").is_none());
}
#[test]
fn skips_logical_copy_paste_triggers() {
assert!(parse_ghostty_keybind_line("keybind = copy=copy_to_clipboard:mixed").is_none());
assert!(parse_ghostty_keybind_line("keybind = paste=paste_from_clipboard").is_none());
}
#[test]
fn parses_full_output() {
let out = "\
keybind = super+c=copy_to_clipboard:mixed
keybind = super+v=paste_from_clipboard
# comment
keybind = super+enter=new_window
";
let binds = parse_ghostty_keybinds(out);
assert_eq!(binds.len(), 3);
}
}
@@ -0,0 +1,488 @@
//! Resolve the global "launch a new jcode" hotkeys from config into a concrete
//! list of (chord, shell command, script file) tuples.
//!
//! There are two layers here:
//!
//! * The **pure** resolver ([`resolve_launch_hotkeys`]) turns a
//! [`LaunchHotkeysConfig`] plus the support-file paths into a list of
//! [`ResolvedLaunchHotkey`]s. An empty config reproduces jcode's historical
//! three built-in hotkeys (home / last project / self-dev), so existing
//! installs behave identically until auto-import bakes a richer per-repo
//! mapping. This layer is unit-tested without touching the machine.
//! * The **macOS** glue ([`chord_to_global_hotkey`]) maps a parsed
//! [`KeyChord`] onto a `global_hotkey::HotKey` so the launchd listener can
//! register it.
//!
//! Keeping the resolver pure means the chord/dir layout the user sees is exactly
//! what we can assert in tests, and the listener stays a thin dispatcher.
use jcode_config_types::{LaunchHotkeyEntry, LaunchHotkeysConfig};
#[cfg(any(test, target_os = "macos"))]
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::keymap::KeyChord;
/// POSIX single-quote escaping (turns `'` into `'\''`). Local copy so this
/// module compiles on every platform without pulling in the macOS-only
/// `macos_terminal` module.
fn escape_shell_single_quotes(input: &str) -> String {
input.replace('\'', r#"'\''"#)
}
/// Build a jcode launch snippet that pauses on non-zero exit so the user can
/// read the error before the terminal closes. Mirrors the macOS launcher's
/// behavior; kept local so the resolver is platform-independent.
#[cfg(any(test, target_os = "macos"))]
fn paused_jcode_shell_command_with_args(exe_path: &str, args: &[String]) -> String {
let escaped_exe = escape_shell_single_quotes(exe_path);
let mut arg_str = String::new();
for arg in args {
arg_str.push_str(" '");
arg_str.push_str(&escape_shell_single_quotes(arg));
arg_str.push('\'');
}
format!(
r#"if [ ! -x '{exe}' ]; then printf 'jcode executable not found.\n'; exit 127; fi; '{exe}'{args}; status=$?; if [ "$status" -ne 0 ]; then printf '\nJcode exited with status %s.\n' "$status"; printf 'Press Enter to close... '; read -r _; fi; exit "$status""#,
exe = escaped_exe,
args = arg_str,
)
}
/// One entry in the listener's `plan.json`: the chord to register and the script
/// to run when it fires. Written by the installer, read by the launchd listener,
/// so the listener stays a thin dispatcher that never re-parses config.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg(any(test, target_os = "macos"))]
pub(crate) struct PlanEntry {
pub chord: String,
pub script: String,
}
/// A fully-resolved launch hotkey ready to be turned into a script and a chord
/// registration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedLaunchHotkey {
/// jcode-style chord string, e.g. `cmd+;`.
pub chord: String,
/// Stable file name for this entry's launch script.
pub script_file_name: String,
/// Shell snippet that `cd`s into the target directory (with a `$HOME`
/// fallback) before launching jcode.
pub cd_prefix: String,
/// Original configured directory target. May be a sentinel such as `$HOME`,
/// `$LAST_DIR`, or `$LAST_REPO`; direct launchers resolve it at fire time.
pub dir: String,
/// Extra CLI args passed to jcode (e.g. `self-dev`).
pub args: Vec<String>,
/// Human label for notices.
pub label: String,
}
/// `cd` snippet that reads a directory from `dir_file` at fire time, falling
/// back to `$HOME` when the file is missing or the directory no longer exists.
fn cd_from_dir_file(dir_file: &str) -> String {
let escaped = escape_shell_single_quotes(dir_file);
format!(
"__jc_dir=\"$(cat '{escaped}' 2>/dev/null)\"; if [ -n \"$__jc_dir\" ] && [ -d \"$__jc_dir\" ]; then cd \"$__jc_dir\"; else cd \"$HOME\"; fi; "
)
}
/// `cd` snippet for a fixed absolute directory, still falling back to `$HOME` if
/// the directory has since been deleted/moved (so a stale baked path never
/// leaves the user in a broken shell).
fn cd_to_fixed_dir(dir: &str) -> String {
let escaped = escape_shell_single_quotes(dir);
format!("if [ -d '{escaped}' ]; then cd '{escaped}'; else cd \"$HOME\"; fi; ")
}
/// Translate one config entry's `dir` (path or sentinel) into a `cd` prefix.
fn cd_prefix_for_dir(dir: &str, last_dir_file: &str, last_repo_file: &str) -> String {
match dir {
"$HOME" => "cd \"$HOME\"; ".to_string(),
"$LAST_DIR" => cd_from_dir_file(last_dir_file),
"$LAST_REPO" => cd_from_dir_file(last_repo_file),
path => cd_to_fixed_dir(path),
}
}
/// A filesystem-safe, stable script name for a chord, e.g. `cmd+[` ->
/// `launch_jcode_cmd_bracketleft.sh`. Collisions are avoided by appending the
/// slot index, since two distinct entries could in theory normalize the same.
fn script_name_for(chord: &str, index: usize) -> String {
let mut slug = String::new();
for ch in chord.chars() {
match ch {
'a'..='z' | '0'..='9' => slug.push(ch),
'A'..='Z' => slug.push(ch.to_ascii_lowercase()),
'+' => slug.push('_'),
';' => slug.push_str("semicolon"),
'\'' => slug.push_str("quote"),
'[' => slug.push_str("bracketleft"),
']' => slug.push_str("bracketright"),
'\\' => slug.push_str("backslash"),
'/' => slug.push_str("slash"),
',' => slug.push_str("comma"),
'.' => slug.push_str("period"),
'-' => slug.push_str("minus"),
'=' => slug.push_str("equal"),
'`' => slug.push_str("backtick"),
_ => slug.push('x'),
}
}
format!("launch_jcode_{index}_{slug}.sh")
}
/// The built-in default entries, used when config has none. Mirrors jcode's
/// historical `Cmd+;` / `Cmd+'` / `Cmd+Shift+'` layout.
pub(crate) fn default_launch_entries() -> Vec<LaunchHotkeyEntry> {
vec![
LaunchHotkeyEntry {
chord: "cmd+;".to_string(),
dir: "$HOME".to_string(),
label: "home".to_string(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+'".to_string(),
dir: "$LAST_DIR".to_string(),
label: "last project".to_string(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+shift+'".to_string(),
dir: "$LAST_REPO".to_string(),
label: "self-dev".to_string(),
self_dev: true,
},
]
}
/// Resolve a config into concrete launch hotkeys. Empty config -> built-in
/// defaults. Invalid chords are skipped (logged by the caller). Duplicate chords
/// keep the first occurrence so a later malformed edit cannot shadow an earlier
/// binding.
pub(crate) fn resolve_launch_hotkeys(
config: &LaunchHotkeysConfig,
exe_path: &str,
last_dir_file: &str,
last_repo_file: &str,
) -> Vec<ResolvedLaunchHotkey> {
let entries: Vec<LaunchHotkeyEntry> = if config.entries.is_empty() {
default_launch_entries()
} else {
config.entries.clone()
};
let mut seen_chords: Vec<String> = Vec::new();
let mut out = Vec::new();
for (index, entry) in entries.iter().enumerate() {
let Some(chord) = KeyChord::parse(&entry.chord) else {
continue;
};
let canonical = chord.canonical();
if seen_chords.contains(&canonical) {
continue;
}
seen_chords.push(canonical.clone());
let cd_prefix = cd_prefix_for_dir(&entry.dir, last_dir_file, last_repo_file);
let args: Vec<String> = if entry.self_dev {
vec!["self-dev".to_string()]
} else {
Vec::new()
};
let _ = exe_path; // shell command is built by the installer; exe kept for symmetry
out.push(ResolvedLaunchHotkey {
chord: canonical,
script_file_name: script_name_for(&entry.chord, index),
cd_prefix,
dir: entry.dir.clone(),
args,
label: if entry.label.is_empty() {
entry.dir.clone()
} else {
entry.label.clone()
},
});
}
out
}
/// Resolve a configured hotkey directory into a concrete cwd for direct spawns.
/// Missing/stale dynamic targets fall back to `$HOME`, matching the shell-script
/// launcher behavior.
pub(crate) fn resolve_target_dir(dir: &str, last_dir_file: &str, last_repo_file: &str) -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
match dir {
"$HOME" => home,
"$LAST_DIR" => read_existing_dir(last_dir_file).unwrap_or(home),
"$LAST_REPO" => read_existing_dir(last_repo_file).unwrap_or(home),
path => {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
home.join(rest)
} else {
PathBuf::from(path)
};
if expanded.is_dir() { expanded } else { home }
}
}
}
fn read_existing_dir(file: &str) -> Option<PathBuf> {
let text = std::fs::read_to_string(file).ok()?;
let path = PathBuf::from(text.trim());
path.is_dir().then_some(path)
}
/// Build the full shell command (run inside the freshly opened terminal) for a
/// resolved hotkey.
#[cfg(any(test, target_os = "macos"))]
pub(crate) fn shell_command_for(entry: &ResolvedLaunchHotkey, exe_path: &str) -> String {
format!(
"{}{}",
entry.cd_prefix,
paused_jcode_shell_command_with_args(exe_path, &entry.args)
)
}
/// Map a jcode key token onto a `global_hotkey` `Code`. Returns `None` for
/// tokens we cannot register (the caller logs and skips).
#[cfg(target_os = "macos")]
pub(crate) fn key_token_to_code(key: &str) -> Option<global_hotkey::hotkey::Code> {
use global_hotkey::hotkey::Code;
Some(match key {
";" => Code::Semicolon,
"'" => Code::Quote,
"[" => Code::BracketLeft,
"]" => Code::BracketRight,
"\\" => Code::Backslash,
"/" => Code::Slash,
"," => Code::Comma,
"." => Code::Period,
"-" => Code::Minus,
"=" => Code::Equal,
"`" => Code::Backquote,
"a" => Code::KeyA,
"b" => Code::KeyB,
"c" => Code::KeyC,
"d" => Code::KeyD,
"e" => Code::KeyE,
"f" => Code::KeyF,
"g" => Code::KeyG,
"h" => Code::KeyH,
"i" => Code::KeyI,
"j" => Code::KeyJ,
"k" => Code::KeyK,
"l" => Code::KeyL,
"m" => Code::KeyM,
"n" => Code::KeyN,
"o" => Code::KeyO,
"p" => Code::KeyP,
"q" => Code::KeyQ,
"r" => Code::KeyR,
"s" => Code::KeyS,
"t" => Code::KeyT,
"u" => Code::KeyU,
"v" => Code::KeyV,
"w" => Code::KeyW,
"x" => Code::KeyX,
"y" => Code::KeyY,
"z" => Code::KeyZ,
"0" => Code::Digit0,
"1" => Code::Digit1,
"2" => Code::Digit2,
"3" => Code::Digit3,
"4" => Code::Digit4,
"5" => Code::Digit5,
"6" => Code::Digit6,
"7" => Code::Digit7,
"8" => Code::Digit8,
"9" => Code::Digit9,
_ => return None,
})
}
/// Map a parsed [`KeyChord`] onto a `global_hotkey::HotKey`. Returns `None` if
/// the key token is not registerable.
#[cfg(target_os = "macos")]
pub(crate) fn chord_to_global_hotkey(chord: &KeyChord) -> Option<global_hotkey::hotkey::HotKey> {
use global_hotkey::hotkey::{HotKey, Modifiers};
let code = key_token_to_code(&chord.key)?;
let mut mods = Modifiers::empty();
if chord.cmd {
mods |= Modifiers::META;
}
if chord.ctrl {
mods |= Modifiers::CONTROL;
}
if chord.alt {
mods |= Modifiers::ALT;
}
if chord.shift {
mods |= Modifiers::SHIFT;
}
Some(HotKey::new(Some(mods), code))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(entries: Vec<LaunchHotkeyEntry>) -> LaunchHotkeysConfig {
LaunchHotkeysConfig {
enabled: Some(true),
entries,
imported: true,
}
}
#[test]
fn empty_config_reproduces_three_builtins() {
let resolved = resolve_launch_hotkeys(
&LaunchHotkeysConfig::default(),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
assert_eq!(resolved.len(), 3);
assert_eq!(resolved[0].chord, "cmd+;");
assert!(resolved[0].cd_prefix.contains("cd \"$HOME\""));
assert!(resolved[0].args.is_empty());
assert_eq!(resolved[1].chord, "cmd+'");
assert!(resolved[1].cd_prefix.contains("/last_dir"));
assert_eq!(resolved[2].chord, "cmd+shift+'");
assert!(resolved[2].cd_prefix.contains("/last_repo"));
assert_eq!(resolved[2].args, vec!["self-dev".to_string()]);
}
#[test]
fn fixed_dir_entry_cds_to_path_with_home_fallback() {
let resolved = resolve_launch_hotkeys(
&cfg(vec![LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/Users/jeremy/proj".to_string(),
label: "proj".to_string(),
self_dev: false,
}]),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].chord, "cmd+[");
assert!(resolved[0].cd_prefix.contains("/Users/jeremy/proj"));
assert!(resolved[0].cd_prefix.contains("cd \"$HOME\""));
assert_eq!(resolved[0].label, "proj");
}
#[test]
fn duplicate_chords_keep_first() {
let resolved = resolve_launch_hotkeys(
&cfg(vec![
LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/a".to_string(),
label: String::new(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/b".to_string(),
label: String::new(),
self_dev: false,
},
]),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
assert_eq!(resolved.len(), 1);
assert!(resolved[0].cd_prefix.contains("/a"));
}
#[test]
fn invalid_chord_is_skipped() {
let resolved = resolve_launch_hotkeys(
&cfg(vec![
LaunchHotkeyEntry {
chord: "none".to_string(),
dir: "/a".to_string(),
label: String::new(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+]".to_string(),
dir: "/b".to_string(),
label: String::new(),
self_dev: false,
},
]),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].chord, "cmd+]");
}
#[test]
fn script_names_are_unique_and_safe() {
let resolved = resolve_launch_hotkeys(
&cfg(vec![
LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/a".to_string(),
label: String::new(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+]".to_string(),
dir: "/b".to_string(),
label: String::new(),
self_dev: false,
},
LaunchHotkeyEntry {
chord: "cmd+\\".to_string(),
dir: "/c".to_string(),
label: String::new(),
self_dev: false,
},
]),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
let names: Vec<&str> = resolved
.iter()
.map(|r| r.script_file_name.as_str())
.collect();
assert_eq!(names.len(), 3);
let mut sorted = names.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), 3, "script names must be unique: {names:?}");
for n in &names {
assert!(n.ends_with(".sh"));
assert!(!n.contains(['[', ']', '\\', '/', ';', '\'']));
}
}
#[test]
fn shell_command_includes_cd_and_exe() {
let resolved = resolve_launch_hotkeys(
&cfg(vec![LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/Users/jeremy/proj".to_string(),
label: "proj".to_string(),
self_dev: false,
}]),
"/bin/jcode",
"/last_dir",
"/last_repo",
);
let cmd = shell_command_for(&resolved[0], "/bin/jcode");
assert!(cmd.contains("/Users/jeremy/proj"));
assert!(cmd.contains("/bin/jcode"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+506
View File
@@ -0,0 +1,506 @@
//! Render and install global "launch a new jcode" hotkeys on Linux/niri.
//!
//! Unlike macOS, a Wayland client cannot grab system-wide hotkeys: the
//! `global-hotkey` crate only works on X11/macOS. The portable, correct
//! mechanism on Wayland is to ask the **compositor** to bind the key, so on niri
//! we generate `bind` lines and splice them into the user's
//! `~/.config/niri/config.kdl` `binds { }` block. niri watches its config and
//! hot-reloads on save, so the bindings take effect without a restart.
//!
//! Two layers, mirroring the macOS module:
//!
//! * The **pure** renderer ([`render_niri_block`], [`chord_to_niri_bind`]) turns
//! resolved launch hotkeys into the exact KDL text we manage. This is what the
//! unit tests assert, so the bindings the user sees are exactly what we can
//! check without touching their machine.
//! * The **install** glue ([`splice_managed_block`]) replaces our marked region
//! inside the existing `binds { }` block (or inserts one), leaving every other
//! line untouched.
//!
//! The managed region is delimited by sentinel comments so re-installs are
//! idempotent and a user can hand-remove it cleanly:
//!
//! ```text
//! // >>> jcode launch hotkeys (managed) >>>
//! Alt+Semicolon hotkey-overlay-title="jcode: home" { spawn "sh" "-c" "..."; }
//! // <<< jcode launch hotkeys (managed) <<<
//! ```
use crate::keymap::KeyChord;
/// Opening sentinel for the managed bind region inside `binds { }`.
pub(crate) const NIRI_BLOCK_BEGIN: &str = "// >>> jcode launch hotkeys (managed) >>>";
/// Closing sentinel for the managed bind region inside `binds { }`.
pub(crate) const NIRI_BLOCK_END: &str = "// <<< jcode launch hotkeys (managed) <<<";
/// One resolved hotkey ready to render as a niri bind: the chord, the target
/// directory, a human label, and whether it is a self-dev session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct NiriHotkey {
pub chord: KeyChord,
/// Concrete directory to launch jcode in (already resolved from any
/// `$HOME`/`$LAST_DIR` sentinel).
pub dir: String,
/// Short human label, e.g. the repo's directory name.
pub label: String,
/// Pass the `self-dev` subcommand.
pub self_dev: bool,
}
/// Map a jcode modifier+key chord onto niri's KDL key syntax.
///
/// niri uses `+`-joined modifiers followed by an XKB key name, e.g.
/// `Alt+Semicolon`, `Super+Shift+Apostrophe`. We translate jcode's `cmd`
/// modifier to `Super` (the Wayland super/meta key) since there is no Command
/// key on Linux. Returns `None` for keys niri cannot name.
pub(crate) fn chord_to_niri_bind(chord: &KeyChord) -> Option<String> {
let key = niri_key_name(&chord.key)?;
let mut parts: Vec<&str> = Vec::new();
// jcode `cmd` == macOS Command == Wayland Super.
if chord.cmd {
parts.push("Super");
}
if chord.ctrl {
parts.push("Ctrl");
}
if chord.alt {
parts.push("Alt");
}
if chord.shift {
parts.push("Shift");
}
let mods = parts.join("+");
if mods.is_empty() {
Some(key)
} else {
Some(format!("{mods}+{key}"))
}
}
/// Translate a canonical jcode key token into the XKB key name niri expects.
/// Returns `None` for tokens with no stable niri spelling.
fn niri_key_name(key: &str) -> Option<String> {
let named = match key {
";" => "Semicolon",
"'" => "Apostrophe",
"[" => "bracketleft",
"]" => "bracketright",
"\\" => "backslash",
"/" => "slash",
"," => "comma",
"." => "period",
"-" => "minus",
"=" => "equal",
"`" => "grave",
"left" => "Left",
"right" => "Right",
"up" => "Up",
"down" => "Down",
"pageup" => "Page_Up",
"pagedown" => "Page_Down",
"home" => "Home",
"end" => "End",
"insert" => "Insert",
"delete" => "Delete",
"backspace" => "BackSpace",
"enter" => "Return",
"esc" => "Escape",
"tab" => "Tab",
"space" => "space",
other => {
// Single letters: niri accepts the lowercase XKB name (`a`..`z`).
if other.len() == 1 && other.chars().all(|c| c.is_ascii_alphanumeric()) {
return Some(other.to_string());
}
// Function keys f1..f24 -> F1..F24.
if let Some(rest) = other.strip_prefix('f')
&& !rest.is_empty()
&& rest.chars().all(|c| c.is_ascii_digit())
{
return Some(format!("F{rest}"));
}
return None;
}
};
Some(named.to_string())
}
/// Escape a string for inclusion inside a KDL double-quoted string.
fn kdl_escape(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
/// POSIX-shell single-quote escaping for an argument passed via `sh -c`.
fn sh_single_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', r#"'\''"#))
}
/// Build the `sh -c` command string a bind runs to open jcode in `dir`.
///
/// We `cd` into the directory (falling back to `$HOME` if it has since been
/// removed), then launch jcode via the user's terminal. The terminal is chosen
/// by `terminal` (e.g. `kitty`); we pass it the jcode executable directly.
fn launch_shell_command(exe_path: &str, terminal: &str, dir: &str, self_dev: bool) -> String {
let dir_q = sh_single_quote(dir);
let exe_q = sh_single_quote(exe_path);
let term_q = sh_single_quote(terminal);
let subcmd = if self_dev { " self-dev" } else { "" };
// cd with $HOME fallback, then exec the terminal running jcode.
format!(
"if [ -d {dir_q} ]; then cd {dir_q}; else cd \"$HOME\"; fi; exec {term_q} {exe_q}{subcmd}",
dir_q = dir_q,
term_q = term_q,
exe_q = exe_q,
subcmd = subcmd,
)
}
/// Render a single niri `bind` line for one hotkey, or `None` if the chord
/// cannot be expressed in niri.
pub(crate) fn render_niri_bind_line(
hotkey: &NiriHotkey,
exe_path: &str,
terminal: &str,
indent: &str,
) -> Option<String> {
let bind = chord_to_niri_bind(&hotkey.chord)?;
let title = if hotkey.self_dev {
format!("jcode: {} (self-dev)", hotkey.label)
} else {
format!("jcode: {}", hotkey.label)
};
let shell = launch_shell_command(exe_path, terminal, &hotkey.dir, hotkey.self_dev);
Some(format!(
"{indent}{bind} hotkey-overlay-title=\"{title}\" {{ spawn \"sh\" \"-c\" \"{shell}\"; }}",
indent = indent,
bind = bind,
title = kdl_escape(&title),
shell = kdl_escape(&shell),
))
}
/// Render the full managed block (sentinels + one bind per hotkey), indented to
/// sit inside `binds { }`. Hotkeys niri cannot express are skipped. Returns
/// `None` when no hotkey could be rendered.
pub(crate) fn render_niri_block(
hotkeys: &[NiriHotkey],
exe_path: &str,
terminal: &str,
indent: &str,
) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
for hk in hotkeys {
if let Some(line) = render_niri_bind_line(hk, exe_path, terminal, indent) {
lines.push(line);
}
}
if lines.is_empty() {
return None;
}
let mut out = String::new();
out.push_str(indent);
out.push_str(NIRI_BLOCK_BEGIN);
out.push('\n');
for line in &lines {
out.push_str(line);
out.push('\n');
}
out.push_str(indent);
out.push_str(NIRI_BLOCK_END);
Some(out)
}
/// Result of splicing the managed block into a config: the new text plus whether
/// anything actually changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SpliceResult {
pub text: String,
pub changed: bool,
}
/// Splice `block` (a fully-rendered managed region, no trailing newline) into
/// `config`'s `binds { }` section.
///
/// Behavior:
/// - If a previous managed region (between the sentinels) exists, replace it in
/// place. This keeps re-installs idempotent and position-stable.
/// - Otherwise, insert the block just inside the opening `binds {` line.
/// - If there is no `binds {` block at all, append a fresh `binds { ... }` at
/// the end of the file.
///
/// Returns `changed = false` (and the original text) when the existing managed
/// region already equals `block`, so callers can skip a no-op write.
pub(crate) fn splice_managed_block(config: &str, block: &str) -> SpliceResult {
// 1) Replace an existing managed region if present.
if let (Some(begin_idx), Some(end_line_end)) = find_managed_region(config) {
// begin_idx is the byte offset of the start of the BEGIN line; the
// managed region runs through the end of the END line (including its
// trailing newline). Re-emit `block` plus that newline so the result is
// byte-identical to a fresh insert (keeps re-installs idempotent).
let before = &config[..begin_idx];
let after = &config[end_line_end..];
let new_text = format!("{before}{block}\n{after}");
let changed = new_text != config;
return SpliceResult {
text: new_text,
changed,
};
}
// 2) Insert just inside an existing `binds {` block.
if let Some(insert_at) = binds_block_insert_point(config) {
let before = &config[..insert_at];
let after = &config[insert_at..];
// Terminate the block with a newline so the END sentinel never runs into
// the following bind line (which would later swallow it on replace).
let new_text = format!("{before}{block}\n{after}");
return SpliceResult {
text: new_text,
changed: true,
};
}
// 3) No binds block: append a new one.
let mut new_text = config.to_string();
if !new_text.is_empty() && !new_text.ends_with('\n') {
new_text.push('\n');
}
new_text.push_str("\nbinds {\n");
new_text.push_str(block);
new_text.push('\n');
new_text.push_str("}\n");
SpliceResult {
text: new_text,
changed: true,
}
}
/// Find the byte range of an existing managed region: `(start_of_BEGIN_line,
/// end_of_END_line_including_newline)`. Returns `None` if either sentinel is
/// missing.
fn find_managed_region(config: &str) -> (Option<usize>, Option<usize>) {
let Some(begin_pos) = config.find(NIRI_BLOCK_BEGIN) else {
return (None, None);
};
// Back up to the start of the BEGIN line (include its indentation).
let line_start = config[..begin_pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
let Some(end_pos) = config[begin_pos..].find(NIRI_BLOCK_END) else {
return (Some(line_start), None);
};
let end_abs = begin_pos + end_pos;
// Extend through the rest of the END line, including its trailing newline.
let line_end = match config[end_abs..].find('\n') {
Some(nl) => end_abs + nl + 1,
None => config.len(),
};
(Some(line_start), Some(line_end))
}
/// Byte offset just after the first `binds {` opening line's newline, i.e. the
/// point to insert new binds so they land inside the block. Returns `None` if no
/// `binds {` block exists.
fn binds_block_insert_point(config: &str) -> Option<usize> {
for (idx, line) in line_offsets(config) {
let trimmed = line.trim_start();
if trimmed.starts_with("binds") && trimmed.trim_end().ends_with('{') {
// Insert right after this line's terminating newline.
let after_line = idx + line.len();
// line includes no newline; advance past it if present.
return Some(if config[after_line..].starts_with('\n') {
after_line + 1
} else {
after_line
});
}
}
None
}
/// Iterate `(byte_offset, line_without_newline)` pairs.
fn line_offsets(s: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut start = 0;
for (i, ch) in s.char_indices() {
if ch == '\n' {
out.push((start, &s[start..i]));
start = i + 1;
}
}
if start < s.len() {
out.push((start, &s[start..]));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn chord(s: &str) -> KeyChord {
KeyChord::parse(s).unwrap()
}
fn hk(chord_str: &str, dir: &str, label: &str, self_dev: bool) -> NiriHotkey {
NiriHotkey {
chord: chord(chord_str),
dir: dir.to_string(),
label: label.to_string(),
self_dev,
}
}
#[test]
fn maps_common_chords_to_niri_syntax() {
// cmd maps to Super on Linux.
assert_eq!(
chord_to_niri_bind(&chord("cmd+;")).unwrap(),
"Super+Semicolon"
);
assert_eq!(
chord_to_niri_bind(&chord("cmd+shift+'")).unwrap(),
"Super+Shift+Apostrophe"
);
assert_eq!(
chord_to_niri_bind(&chord("alt+[")).unwrap(),
"Alt+bracketleft"
);
assert_eq!(
chord_to_niri_bind(&chord("ctrl+\\")).unwrap(),
"Ctrl+backslash"
);
assert_eq!(chord_to_niri_bind(&chord("alt+b")).unwrap(), "Alt+b");
}
#[test]
fn rejects_unmappable_keys() {
// An empty/odd token has no niri name.
assert!(niri_key_name("scrolllock").is_none());
}
#[test]
fn renders_bind_line_with_cd_and_terminal() {
let line = render_niri_bind_line(
&hk("alt+;", "/home/jeremy/jcode", "jcode", true),
"/home/jeremy/.local/bin/jcode",
"kitty",
" ",
)
.unwrap();
assert!(line.contains("Alt+Semicolon"));
assert!(line.contains("self-dev"));
assert!(line.contains("hotkey-overlay-title=\"jcode: jcode (self-dev)\""));
assert!(line.contains("spawn \"sh\" \"-c\""));
assert!(line.contains("/home/jeremy/jcode"));
assert!(line.starts_with(" "));
}
#[test]
fn render_block_wraps_sentinels() {
let block = render_niri_block(
&[
hk("alt+;", "/home/u", "home", false),
hk("alt+'", "/home/u/proj", "proj", false),
],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
assert!(block.starts_with(" // >>> jcode launch hotkeys (managed) >>>"));
assert!(
block
.trim_end()
.ends_with("// <<< jcode launch hotkeys (managed) <<<")
);
assert_eq!(block.matches("spawn").count(), 2);
}
#[test]
fn splice_inserts_into_existing_binds_block() {
let cfg = "binds {\n Alt+Tab { focus-window-previous; }\n}\n";
let block = render_niri_block(
&[hk("alt+;", "/home/u", "home", false)],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
let res = splice_managed_block(cfg, &block);
assert!(res.changed);
assert!(res.text.contains(NIRI_BLOCK_BEGIN));
assert!(res.text.contains("Alt+Tab { focus-window-previous; }"));
// Managed block sits after the binds { line.
let binds_idx = res.text.find("binds {").unwrap();
let begin_idx = res.text.find(NIRI_BLOCK_BEGIN).unwrap();
assert!(begin_idx > binds_idx);
}
#[test]
fn splice_replaces_existing_managed_region_in_place() {
let block_v1 = render_niri_block(
&[hk("alt+;", "/home/u", "home", false)],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
let cfg = format!("binds {{\n{block_v1}\n Alt+Tab {{ focus-window-previous; }}\n}}\n");
let block_v2 = render_niri_block(
&[hk("alt+;", "/home/u/newproj", "newproj", false)],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
let res = splice_managed_block(&cfg, &block_v2);
assert!(res.changed);
// Only one managed region.
assert_eq!(res.text.matches(NIRI_BLOCK_BEGIN).count(), 1);
assert!(res.text.contains("newproj"));
assert!(!res.text.contains("\"home\""));
// Untouched bind preserved.
assert!(res.text.contains("Alt+Tab { focus-window-previous; }"));
}
#[test]
fn splice_is_idempotent() {
let block = render_niri_block(
&[hk("alt+;", "/home/u", "home", false)],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
let cfg = "binds {\n Alt+Tab { focus-window-previous; }\n}\n";
let first = splice_managed_block(cfg, &block);
let second = splice_managed_block(&first.text, &block);
assert!(!second.changed);
assert_eq!(first.text, second.text);
}
#[test]
fn splice_appends_binds_block_when_missing() {
let block = render_niri_block(
&[hk("alt+;", "/home/u", "home", false)],
"/bin/jcode",
"kitty",
" ",
)
.unwrap();
let cfg = "// no binds here\noutput \"eDP-1\" {}\n";
let res = splice_managed_block(cfg, &block);
assert!(res.changed);
assert!(res.text.contains("binds {"));
assert!(res.text.contains(NIRI_BLOCK_BEGIN));
}
#[test]
fn shell_command_cds_and_self_devs() {
let s = launch_shell_command("/bin/jcode", "kitty", "/home/u/proj", true);
assert!(s.contains("cd '/home/u/proj'"));
assert!(s.contains("exec 'kitty' '/bin/jcode' self-dev"));
assert!(s.contains("\"$HOME\""));
}
}
@@ -0,0 +1,260 @@
use super::{
MacTerminalKind, SetupHintsState, effective_macos_terminal, escape_applescript_text,
escape_shell_single_quotes, launch_command_for_macos_terminal, paused_jcode_shell_command,
save_preferred_macos_terminal,
};
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const MACOS_APP_ICON_FILE_NAME: &str = "Jcode.icns";
const MACOS_APP_ICON_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../assets/app-icons/Jcode.icns"
));
pub(super) fn should_refresh_macos_app_launcher(state: &SetupHintsState) -> bool {
match (macos_app_launcher_dir(), legacy_macos_app_launcher_dir()) {
(Ok(app_dir), Ok(legacy_app_dir)) => {
should_refresh_macos_app_launcher_paths(state, &app_dir, &legacy_app_dir)
}
_ => !state.desktop_shortcut_created,
}
}
pub(super) fn install_macos_app_launcher() -> Result<(PathBuf, MacTerminalKind)> {
let app_dir = macos_app_launcher_dir()?;
let legacy_app_dir = legacy_macos_app_launcher_dir()?;
if app_dir.exists() && !macos_app_launcher_is_valid(&app_dir) {
remove_path_if_exists(&app_dir)?;
}
if legacy_app_dir != app_dir && legacy_app_dir.exists() {
remove_path_if_exists(&legacy_app_dir)?;
}
let contents_dir = app_dir.join("Contents");
let macos_dir = contents_dir.join("MacOS");
let resources_dir = contents_dir.join("Resources");
std::fs::create_dir_all(&macos_dir)?;
std::fs::create_dir_all(&resources_dir)?;
let exe = std::env::current_exe()?;
let exe_path = exe.to_string_lossy().into_owned();
let terminal = effective_macos_terminal();
let launcher_path = macos_dir.join("jcode-launcher");
let launcher_script = macos_launcher_script(terminal, &exe_path, &app_dir);
std::fs::write(&launcher_path, launcher_script)?;
std::fs::write(
resources_dir.join(MACOS_APP_ICON_FILE_NAME),
MACOS_APP_ICON_BYTES,
)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&launcher_path, std::fs::Permissions::from_mode(0o755))?;
}
let info_plist = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Jcode</string>
<key>CFBundleDisplayName</key>
<string>Jcode</string>
<key>CFBundleIdentifier</key>
<string>com.jcode.launcher</string>
<key>CFBundleVersion</key>
<string>{version}</string>
<key>CFBundleShortVersionString</key>
<string>{version}</string>
<key>CFBundleExecutable</key>
<string>jcode-launcher</string>
<key>CFBundleIconFile</key>
<string>{icon_file}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
</dict>
</plist>
"#,
version = jcode_build_meta::VERSION,
icon_file = MACOS_APP_ICON_FILE_NAME,
);
std::fs::write(contents_dir.join("Info.plist"), info_plist)?;
if !macos_app_launcher_is_valid(&app_dir) {
anyhow::bail!(
"launcher bundle is incomplete after setup: {}",
app_dir.display()
);
}
register_macos_app_launcher(&app_dir);
save_preferred_macos_terminal(terminal)?;
Ok((app_dir, terminal))
}
fn macos_app_launcher_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not find home directory")?;
Ok(home.join("Applications").join("Jcode.app"))
}
fn legacy_macos_app_launcher_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not find home directory")?;
Ok(home.join("Applications").join("jcode.app"))
}
fn macos_app_launcher_info_plist_path(app_dir: &Path) -> PathBuf {
app_dir.join("Contents").join("Info.plist")
}
fn macos_app_launcher_executable_path(app_dir: &Path) -> PathBuf {
app_dir
.join("Contents")
.join("MacOS")
.join("jcode-launcher")
}
fn macos_app_launcher_icon_path(app_dir: &Path) -> PathBuf {
app_dir
.join("Contents")
.join("Resources")
.join(MACOS_APP_ICON_FILE_NAME)
}
fn macos_app_launcher_is_valid(app_dir: &Path) -> bool {
app_dir.is_dir()
&& macos_app_launcher_info_plist_path(app_dir).is_file()
&& macos_app_launcher_executable_path(app_dir).is_file()
&& macos_app_launcher_icon_path(app_dir).is_file()
}
fn remove_path_if_exists(path: &Path) -> Result<()> {
if !path.exists() {
return Ok(());
}
let metadata = std::fs::symlink_metadata(path)
.with_context(|| format!("failed to inspect existing path {}", path.display()))?;
if metadata.file_type().is_dir() {
std::fs::remove_dir_all(path)
.with_context(|| format!("failed to remove directory {}", path.display()))?;
} else {
std::fs::remove_file(path)
.with_context(|| format!("failed to remove file {}", path.display()))?;
}
Ok(())
}
fn register_macos_app_launcher(app_dir: &Path) {
let _ = std::process::Command::new("touch").arg(app_dir).status();
let lsregister = Path::new(
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
);
if lsregister.exists() {
let _ = std::process::Command::new(lsregister)
.args(["-f", app_dir.to_string_lossy().as_ref()])
.status();
}
let _ = std::process::Command::new("mdimport").arg(app_dir).status();
}
fn should_refresh_macos_app_launcher_paths(
state: &SetupHintsState,
app_dir: &Path,
legacy_app_dir: &Path,
) -> bool {
!state.desktop_shortcut_created
|| !macos_app_launcher_is_valid(app_dir)
|| path_exists_with_exact_name(legacy_app_dir)
}
/// Check that `path` exists under its exact byte-for-byte file name.
///
/// macOS system volumes are case-insensitive by default, so a plain
/// `Path::exists()` on `jcode.app` also matches `Jcode.app`. The legacy-bundle
/// check needs an exact-name match or the launcher would refresh itself on
/// every launch once the new bundle exists.
fn path_exists_with_exact_name(path: &Path) -> bool {
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
return path.exists();
};
let Ok(entries) = std::fs::read_dir(parent) else {
return false;
};
entries
.filter_map(|entry| entry.ok())
.any(|entry| entry.file_name() == name)
}
fn macos_launcher_script(terminal: MacTerminalKind, exe_path: &str, app_dir: &Path) -> String {
let app_dir_escaped = escape_shell_single_quotes(&app_dir.to_string_lossy());
let exe_path_escaped = escape_shell_single_quotes(exe_path);
let shell_command = paused_jcode_shell_command(exe_path);
let launch_command = launch_command_for_macos_terminal(terminal, &shell_command);
let missing_message = escape_applescript_text(&format!(
"Jcode could not launch because the executable was not found.\n\nExpected path:\n{}\n\nTry reinstalling jcode or rerun:\njcode setup-launcher",
exe_path
));
let terminal_failure_message = escape_applescript_text(&format!(
"Jcode could not open {}.\n\nTry rerunning:\njcode setup-launcher\n\nLauncher log:\n~/.jcode/launcher/macos-launcher.log",
terminal.label()
));
format!(
r#"#!/bin/bash
set -u
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
LOG_DIR="$HOME/.jcode/launcher"
LOG_FILE="$LOG_DIR/macos-launcher.log"
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
show_missing_executable() {{
/usr/bin/osascript <<'APPLESCRIPT' >/dev/null 2>&1 || true
display alert "Jcode launch failed" message "{missing_message}" as critical
APPLESCRIPT
}}
show_terminal_launch_failure() {{
/usr/bin/osascript <<'APPLESCRIPT' >/dev/null 2>&1 || true
display alert "Jcode launch failed" message "{terminal_failure_message}" as critical
APPLESCRIPT
}}
if [ ! -x '{exe_path_escaped}' ]; then
printf '[%s] missing executable: {exe_path}\n' "$(date '+%Y-%m-%d %H:%M:%S')" >>"$LOG_FILE" 2>&1
show_missing_executable
exit 1
fi
{launch_command} >>"$LOG_FILE" 2>&1
status=$?
if [ "$status" -ne 0 ]; then
printf '[%s] terminal launch failed for {terminal_name} (status %s)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$status" >>"$LOG_FILE" 2>&1
show_terminal_launch_failure
exit "$status"
fi
/usr/bin/touch '{app_dir_escaped}' >/dev/null 2>&1 || true
exit 0
"#,
missing_message = missing_message,
terminal_failure_message = terminal_failure_message,
exe_path = exe_path,
exe_path_escaped = exe_path_escaped,
terminal_name = terminal.label(),
launch_command = launch_command,
app_dir_escaped = app_dir_escaped,
)
}
#[cfg(test)]
#[path = "macos_launcher_tests.rs"]
mod macos_launcher_tests;
@@ -0,0 +1,142 @@
use super::*;
#[test]
fn macos_launcher_script_shows_alerts_and_uses_terminal_launcher() {
let script = macos_launcher_script(
MacTerminalKind::Ghostty,
"/tmp/jcode",
Path::new("/Users/test/Applications/Jcode.app"),
);
assert!(script.contains("display alert \"Jcode launch failed\""));
assert!(script.contains("jcode setup-launcher"));
assert!(script.contains("/usr/bin/open -na Ghostty"));
assert!(script.contains("macos-launcher.log"));
}
#[test]
fn macos_launcher_icon_asset_is_valid_icns_container() {
assert!(MACOS_APP_ICON_BYTES.starts_with(b"icns"));
assert!(MACOS_APP_ICON_BYTES.len() > 1024);
}
#[test]
fn macos_launcher_refreshes_when_new_bundle_missing() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
#[test]
fn macos_launcher_refreshes_when_legacy_bundle_exists() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
std::fs::create_dir_all(&app_dir).expect("create new app dir");
std::fs::create_dir_all(&legacy_app_dir).expect("create legacy app dir");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
#[test]
fn macos_launcher_refreshes_when_new_bundle_is_plain_file() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
std::fs::write(&app_dir, "broken").expect("write broken launcher file");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
#[test]
fn macos_launcher_refreshes_when_bundle_is_incomplete() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
std::fs::create_dir_all(app_dir.join("Contents")).expect("create incomplete bundle");
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(!macos_app_launcher_is_valid(&app_dir));
assert!(should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
#[test]
fn macos_launcher_does_not_refresh_when_new_bundle_exists() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
std::fs::create_dir_all(app_dir.join("Contents").join("MacOS")).expect("create new app dir");
std::fs::create_dir_all(app_dir.join("Contents").join("Resources"))
.expect("create resources dir");
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
std::fs::write(macos_app_launcher_executable_path(&app_dir), "#!/bin/sh\n")
.expect("write launcher executable");
std::fs::write(macos_app_launcher_icon_path(&app_dir), MACOS_APP_ICON_BYTES)
.expect("write launcher icon");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(macos_app_launcher_is_valid(&app_dir));
assert!(!should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
#[test]
fn macos_launcher_refreshes_when_icon_missing() {
let temp = tempfile::tempdir().expect("tempdir");
let app_dir = temp.path().join("Jcode.app");
let legacy_app_dir = temp.path().join("jcode.app");
std::fs::create_dir_all(app_dir.join("Contents").join("MacOS")).expect("create new app dir");
std::fs::write(macos_app_launcher_info_plist_path(&app_dir), "plist").expect("write plist");
std::fs::write(macos_app_launcher_executable_path(&app_dir), "#!/bin/sh\n")
.expect("write launcher executable");
let state = SetupHintsState {
desktop_shortcut_created: true,
..SetupHintsState::default()
};
assert!(!macos_app_launcher_is_valid(&app_dir));
assert!(should_refresh_macos_app_launcher_paths(
&state,
&app_dir,
&legacy_app_dir,
));
}
@@ -0,0 +1,414 @@
use anyhow::Result;
use jcode_storage as storage;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum MacTerminalKind {
Ghostty,
Iterm2,
AppleTerminal,
WezTerm,
Warp,
Alacritty,
Vscode,
Unknown,
}
impl MacTerminalKind {
pub(super) fn label(self) -> &'static str {
match self {
Self::Ghostty => "Ghostty",
Self::Iterm2 => "iTerm2",
Self::AppleTerminal => "Terminal.app",
Self::WezTerm => "WezTerm",
Self::Warp => "Warp",
Self::Alacritty => "Alacritty",
Self::Vscode => "VS Code terminal",
Self::Unknown => "your current terminal",
}
}
pub(super) fn cli_value(self) -> &'static str {
match self {
Self::Ghostty => "ghostty",
Self::Iterm2 => "iterm2",
Self::AppleTerminal => "terminal",
Self::WezTerm => "wezterm",
Self::Warp => "warp",
Self::Alacritty => "alacritty",
Self::Vscode => "vscode",
Self::Unknown => "terminal",
}
}
fn from_cli_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"ghostty" => Some(Self::Ghostty),
"iterm2" | "iterm" => Some(Self::Iterm2),
"terminal" | "terminal.app" | "apple_terminal" => Some(Self::AppleTerminal),
"wezterm" => Some(Self::WezTerm),
"warp" => Some(Self::Warp),
"alacritty" => Some(Self::Alacritty),
"vscode" | "code" => Some(Self::Vscode),
_ => None,
}
}
fn open_command_app_and_args(self) -> Option<(&'static str, &'static str)> {
match self {
Self::Ghostty => Some(("Ghostty", "-e /bin/bash -lc")),
Self::Alacritty => Some(("Alacritty", "-e /bin/bash -lc")),
Self::WezTerm => Some(("WezTerm", "start --always-new-process -- /bin/bash -lc")),
Self::Iterm2 | Self::AppleTerminal | Self::Warp | Self::Vscode | Self::Unknown => None,
}
}
}
impl fmt::Display for MacTerminalKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MacTerminalPreference {
terminal: String,
}
fn mac_terminal_pref_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?.join("preferred_terminal.json"))
}
pub(super) fn load_preferred_macos_terminal() -> Option<MacTerminalKind> {
let path = mac_terminal_pref_path().ok()?;
let pref: MacTerminalPreference = storage::read_json(&path).ok()?;
MacTerminalKind::from_cli_value(&pref.terminal)
}
pub(super) fn save_preferred_macos_terminal(terminal: MacTerminalKind) -> Result<()> {
let path = mac_terminal_pref_path()?;
storage::write_json(
&path,
&MacTerminalPreference {
terminal: terminal.cli_value().to_string(),
},
)
}
pub(super) fn effective_macos_terminal() -> MacTerminalKind {
// Precedence: config.toml `[terminal] preferred` (discoverable, documented)
// > legacy `~/.jcode/preferred_terminal.json` > runtime detection.
config_preferred_macos_terminal()
.or_else(load_preferred_macos_terminal)
.unwrap_or_else(detect_macos_terminal)
}
/// Read `[terminal] preferred` from `~/.jcode/config.toml`, if present and valid.
fn config_preferred_macos_terminal() -> Option<MacTerminalKind> {
#[derive(Deserialize, Default)]
struct Wrapper {
#[serde(default)]
terminal: jcode_config_types::TerminalConfig,
}
let dir = storage::jcode_dir().ok()?;
let text = std::fs::read_to_string(dir.join("config.toml")).ok()?;
let wrapper = toml::from_str::<Wrapper>(&text).ok()?;
let preferred = wrapper.terminal.preferred?;
MacTerminalKind::from_cli_value(&preferred)
}
fn detect_macos_terminal() -> MacTerminalKind {
let term_program = std::env::var("TERM_PROGRAM")
.unwrap_or_default()
.to_lowercase();
let term = std::env::var("TERM").unwrap_or_default().to_lowercase();
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|| std::env::var("GHOSTTY_BIN_DIR").is_ok()
|| term_program == "ghostty"
|| term.contains("ghostty")
{
return MacTerminalKind::Ghostty;
}
match term_program.as_str() {
"iterm.app" => MacTerminalKind::Iterm2,
"apple_terminal" => MacTerminalKind::AppleTerminal,
"wezterm" => MacTerminalKind::WezTerm,
"vscode" => MacTerminalKind::Vscode,
_ => {
if term.contains("alacritty") {
MacTerminalKind::Alacritty
} else if term.contains("warp") {
MacTerminalKind::Warp
} else {
MacTerminalKind::Unknown
}
}
}
}
pub(super) fn escape_shell_single_quotes(input: &str) -> String {
input.replace('\'', r#"'\''"#)
}
pub(super) fn escape_applescript_text(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(super) fn paused_jcode_shell_command(exe_path: &str) -> String {
paused_jcode_shell_command_with_args(exe_path, &[])
}
/// Like [`paused_jcode_shell_command`] but passes extra CLI args (each
/// single-quoted) to the jcode invocation, e.g. `--resume <session-id>`.
pub(super) fn paused_jcode_shell_command_with_args(exe_path: &str, args: &[String]) -> String {
let escaped_exe = escape_shell_single_quotes(exe_path);
let mut arg_str = String::new();
for arg in args {
arg_str.push_str(" '");
arg_str.push_str(&escape_shell_single_quotes(arg));
arg_str.push('\'');
}
format!(
r#"if [ ! -x '{exe}' ]; then printf 'jcode executable not found.\n'; exit 127; fi; '{exe}'{args}; status=$?; if [ "$status" -ne 0 ]; then printf '\nJcode exited with status %s.\n' "$status"; printf 'Press Enter to close... '; read -r _; fi; exit "$status""#,
exe = escaped_exe,
args = arg_str,
)
}
fn open_command_for_terminal(app_name: &str, app_args: &str, shell_command: &str) -> String {
let escaped_shell = escape_shell_single_quotes(shell_command);
format!("/usr/bin/open -na {app_name} --args {app_args} '{escaped_shell}'")
}
/// Wrap a POSIX/bash launcher snippet so it always runs under bash, regardless
/// of the user's login shell.
///
/// macOS `do script` (Terminal.app/Warp/VS Code) and iTerm's `command` run the
/// string in the user's *default login shell*. Our launcher snippet is plain
/// bash (`status=$?`, `[ ... ]`, `read -r _`), which non-POSIX shells like fish
/// cannot parse (`fish: Unsupported use of '='`). Running it through
/// `/bin/bash -lc` makes the launcher shell-agnostic, matching what the
/// open-command terminals (Ghostty/Alacritty/WezTerm) already do.
fn run_in_bash_login(shell_command: &str) -> String {
format!(
"/bin/bash -lc '{}'",
escape_shell_single_quotes(shell_command)
)
}
fn applescript_command_for_terminal(app_name: &str, shell_command: &str) -> String {
let bash_command = run_in_bash_login(shell_command);
format!(
"/usr/bin/osascript <<'APPLESCRIPT'\ntell application \"{app_name}\"\n activate\n do script \"{}\"\nend tell\nAPPLESCRIPT",
escape_applescript_text(&bash_command)
)
}
fn applescript_command_for_iterm(shell_command: &str) -> String {
let bash_command = run_in_bash_login(shell_command);
format!(
"/usr/bin/osascript <<'APPLESCRIPT'\ntell application \"iTerm2\"\n create window with default profile command \"{}\"\n activate\nend tell\nAPPLESCRIPT",
escape_applescript_text(&bash_command)
)
}
pub(super) fn launch_command_for_macos_terminal(
terminal: MacTerminalKind,
shell_command: &str,
) -> String {
if let Some((app_name, app_args)) = terminal.open_command_app_and_args() {
return open_command_for_terminal(app_name, app_args, shell_command);
}
match terminal {
MacTerminalKind::Iterm2 => applescript_command_for_iterm(shell_command),
MacTerminalKind::AppleTerminal
| MacTerminalKind::Warp
| MacTerminalKind::Vscode
| MacTerminalKind::Unknown => applescript_command_for_terminal("Terminal", shell_command),
MacTerminalKind::Ghostty | MacTerminalKind::WezTerm | MacTerminalKind::Alacritty => {
unreachable!("open-command terminals should be handled above")
}
}
}
#[cfg(target_os = "macos")]
pub(super) fn launch_script_for_macos_terminal(
terminal: MacTerminalKind,
shell_command: &str,
) -> String {
format!(
"#!/bin/bash\nset -e\n{}\n",
launch_command_for_macos_terminal(terminal, shell_command)
)
}
/// How to launch a shell command in a new terminal window without Apple
/// Events automation. Background helpers (the menu bar app, launchd agents)
/// cannot reliably get the "control Terminal" TCC permission that the
/// AppleScript launch path needs, so they use this strategy instead.
pub(super) enum NoAutomationLaunch {
/// Run this shell command directly (terminals launchable via
/// `open -na <App> --args ...`).
Shell(String),
/// Write the shell command to an executable `.command` file and open it
/// with the named app (`None` = system default handler, Terminal.app).
CommandFile { app: Option<&'static str> },
}
pub(super) fn no_automation_launch(
terminal: MacTerminalKind,
shell_command: &str,
) -> NoAutomationLaunch {
if let Some((app_name, app_args)) = terminal.open_command_app_and_args() {
return NoAutomationLaunch::Shell(open_command_for_terminal(
app_name,
app_args,
shell_command,
));
}
match terminal {
MacTerminalKind::Iterm2 => NoAutomationLaunch::CommandFile { app: Some("iTerm") },
_ => NoAutomationLaunch::CommandFile { app: None },
}
}
#[cfg(test)]
mod tests {
use super::{
MacTerminalKind, applescript_command_for_iterm, applescript_command_for_terminal,
launch_command_for_macos_terminal, open_command_for_terminal,
};
#[test]
fn from_cli_value_maps_documented_config_values() {
// These are the values documented for `[terminal] preferred` in
// config.toml; they must round-trip to a known terminal kind (#401).
let cases = [
("ghostty", MacTerminalKind::Ghostty),
("iterm2", MacTerminalKind::Iterm2),
("iterm", MacTerminalKind::Iterm2),
("terminal", MacTerminalKind::AppleTerminal),
("wezterm", MacTerminalKind::WezTerm),
("warp", MacTerminalKind::Warp),
("alacritty", MacTerminalKind::Alacritty),
("vscode", MacTerminalKind::Vscode),
("code", MacTerminalKind::Vscode),
(" Ghostty ", MacTerminalKind::Ghostty),
];
for (value, expected) in cases {
assert_eq!(
MacTerminalKind::from_cli_value(value),
Some(expected),
"config value {value:?} should map to {expected:?}"
);
}
assert_eq!(MacTerminalKind::from_cli_value("nope"), None);
}
#[test]
fn open_command_terminals_use_open_with_expected_args() {
let shell_command = "printf 'hi'";
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::Ghostty, shell_command),
open_command_for_terminal("Ghostty", "-e /bin/bash -lc", shell_command)
);
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::Alacritty, shell_command),
open_command_for_terminal("Alacritty", "-e /bin/bash -lc", shell_command)
);
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::WezTerm, shell_command),
open_command_for_terminal(
"WezTerm",
"start --always-new-process -- /bin/bash -lc",
shell_command,
)
);
}
#[test]
fn paused_shell_command_quotes_extra_args() {
let cmd = super::paused_jcode_shell_command_with_args(
"/usr/local/bin/jcode",
&["--resume".to_string(), "session_fox_123_abc".to_string()],
);
assert!(cmd.contains("'/usr/local/bin/jcode' '--resume' 'session_fox_123_abc';"));
// Single quotes in args must be escaped, not break out of quoting.
let cmd = super::paused_jcode_shell_command_with_args(
"/usr/local/bin/jcode",
&["it's".to_string()],
);
assert!(cmd.contains(r#"'it'\''s'"#));
// No args matches the plain command.
assert_eq!(
super::paused_jcode_shell_command_with_args("/usr/local/bin/jcode", &[]),
super::paused_jcode_shell_command("/usr/local/bin/jcode"),
);
}
#[test]
fn applescript_terminals_use_expected_launcher_commands() {
let shell_command = r#"echo "hi""#;
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::Iterm2, shell_command),
applescript_command_for_iterm(shell_command)
);
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::AppleTerminal, shell_command),
applescript_command_for_terminal("Terminal", shell_command)
);
assert_eq!(
launch_command_for_macos_terminal(MacTerminalKind::Warp, shell_command),
applescript_command_for_terminal("Terminal", shell_command)
);
}
#[test]
fn applescript_launchers_run_under_bash_for_non_bash_login_shells() {
// The launcher snippet is bash-specific (`status=$?`). `do script` and
// iTerm `command` run in the user's login shell, so a fish/zsh-quirky
// login shell would otherwise choke ("fish: Unsupported use of '='").
// Both must wrap the snippet in `/bin/bash -lc`.
let shell_command = super::paused_jcode_shell_command("/usr/local/bin/jcode");
assert!(shell_command.contains("status=$?"));
for terminal in [
MacTerminalKind::AppleTerminal,
MacTerminalKind::Warp,
MacTerminalKind::Vscode,
MacTerminalKind::Unknown,
MacTerminalKind::Iterm2,
] {
let launcher = launch_command_for_macos_terminal(terminal, &shell_command);
assert!(
launcher.contains("/bin/bash -lc"),
"{terminal:?} launcher must run under bash: {launcher}"
);
}
}
#[test]
fn open_command_terminals_pass_snippet_to_bash_not_login_shell() {
// Ghostty/Alacritty/WezTerm wrap via `open -na ... -e /bin/bash -lc`,
// so the bash-specific snippet never reaches the login shell.
let shell_command = super::paused_jcode_shell_command("/usr/local/bin/jcode");
for terminal in [
MacTerminalKind::Ghostty,
MacTerminalKind::Alacritty,
MacTerminalKind::WezTerm,
] {
let launcher = launch_command_for_macos_terminal(terminal, &shell_command);
assert!(
launcher.contains("/bin/bash -lc"),
"{terminal:?} launcher must run under bash: {launcher}"
);
}
}
}
@@ -0,0 +1,548 @@
use super::*;
#[test]
fn first_launch_shows_explicit_alignment_hint_first() {
let state = SetupHintsState {
launch_count: 1,
..SetupHintsState::default()
};
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
assert_eq!(
hints.status_notice.as_deref(),
Some("Tip: `/alignment centered` or Alt+C toggles alignment.")
);
let (title, message) = hints.display_message.expect("expected display message");
assert_eq!(title, "Alignment");
assert!(message.contains("Alt+C"));
assert!(message.contains("/alignment centered"));
assert!(message.contains("left-aligned by default"));
assert!(!message.contains("display.centered = true"));
}
#[test]
fn second_and_third_launches_include_alignment_tip() {
let state = SetupHintsState {
launch_count: 2,
..SetupHintsState::default()
};
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
assert_eq!(
hints.status_notice.as_deref(),
Some("Tip: Alt+C toggles left/center alignment.")
);
let (title, message) = hints.display_message.expect("expected display message");
assert_eq!(title, "Welcome");
assert!(message.contains("Alt+C"));
assert!(message.contains("/alignment centered"));
assert!(message.contains("/alignment left"));
assert!(message.contains("display.centered = true"));
assert!(message.contains("Left-aligned mode is the default"));
}
#[test]
fn launches_after_third_do_not_show_generic_alignment_tip() {
let state = SetupHintsState {
launch_count: 4,
..SetupHintsState::default()
};
assert!(startup_hints_for_launch(&state).is_none());
}
// Asserts the macOS-specific spawn notice text (`Cmd+;` etc.), so it only makes
// sense on macOS. On other platforms the notice uses different chords/wording.
#[cfg(target_os = "macos")]
#[test]
fn first_three_launches_can_include_hotkey_notice_too() {
let state = SetupHintsState {
launch_count: 2,
hotkey_configured: true,
..SetupHintsState::default()
};
let hints = startup_hints_for_launch(&state).expect("expected startup hint");
let (_, message) = hints.display_message.expect("expected display message");
assert!(message.contains("Alt+C"));
assert!(message.contains("Cmd+;"));
// The notice should make clear the hotkey works globally, not just inside jcode.
assert!(message.contains("system-wide"));
// All three launch hotkeys should be mentioned.
assert!(message.contains("Cmd+'"));
assert!(message.contains("Cmd+Shift+'"));
}
#[test]
fn default_resolved_hotkeys_match_legacy_three() {
// With no config, the resolver reproduces the historical three hotkeys.
let resolved = launch_hotkeys::resolve_launch_hotkeys(
&jcode_config_types::LaunchHotkeysConfig::default(),
"/usr/local/bin/jcode",
"/home/u/.jcode/hotkey/last_dir",
"/home/u/.jcode/hotkey/last_repo",
);
let chords: Vec<&str> = resolved.iter().map(|r| r.chord.as_str()).collect();
assert_eq!(chords, vec!["cmd+;", "cmd+'", "cmd+shift+'"]);
// Home launch passes no extra subcommand; self-dev passes `self-dev`.
let home = launch_hotkeys::shell_command_for(&resolved[0], "/usr/local/bin/jcode");
assert!(home.starts_with("cd \"$HOME\"; "));
assert!(!home.contains("self-dev"));
let last_dir = launch_hotkeys::shell_command_for(&resolved[1], "/usr/local/bin/jcode");
assert!(last_dir.contains("cat '/home/u/.jcode/hotkey/last_dir'"));
assert!(last_dir.contains("cd \"$HOME\""));
let selfdev = launch_hotkeys::shell_command_for(&resolved[2], "/usr/local/bin/jcode");
assert!(selfdev.contains("cat '/home/u/.jcode/hotkey/last_repo'"));
assert!(selfdev.contains("'/usr/local/bin/jcode' 'self-dev';"));
}
#[test]
fn baked_repo_hotkey_cds_into_fixed_dir() {
// A config-baked per-repo hotkey opens a fixed directory.
let config = jcode_config_types::LaunchHotkeysConfig {
enabled: Some(true),
imported: true,
entries: vec![jcode_config_types::LaunchHotkeyEntry {
chord: "cmd+[".to_string(),
dir: "/Users/jeremy/jcode-github".to_string(),
label: "jcode-github".to_string(),
self_dev: false,
}],
};
let resolved = launch_hotkeys::resolve_launch_hotkeys(
&config,
"/usr/local/bin/jcode",
"/home/u/.jcode/hotkey/last_dir",
"/home/u/.jcode/hotkey/last_repo",
);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].chord, "cmd+[");
let cmd = launch_hotkeys::shell_command_for(&resolved[0], "/usr/local/bin/jcode");
assert!(cmd.contains("/Users/jeremy/jcode-github"));
assert!(cmd.contains("cd \"$HOME\""), "must keep a home fallback");
assert!(!cmd.contains("self-dev"));
}
#[test]
fn should_record_last_dir_skips_home_only() {
use std::path::Path;
let home = Path::new("/Users/jeremy");
// Home itself is skipped (Cmd+; already covers home).
assert!(!super::should_record_last_dir(home, Some(home)));
// Any other project dir is recorded for Cmd+'.
assert!(super::should_record_last_dir(
Path::new("/Users/jeremy/projects/foo"),
Some(home)
));
// With no known home, always record.
assert!(super::should_record_last_dir(home, None));
}
#[cfg(target_os = "macos")]
#[test]
fn install_writes_executable_scripts_and_plan() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let resolved = launch_hotkeys::resolve_launch_hotkeys(
&jcode_config_types::LaunchHotkeysConfig::default(),
"/usr/local/bin/jcode",
"/home/u/.jcode/hotkey/last_dir",
"/home/u/.jcode/hotkey/last_repo",
);
let plan = super::write_hotkey_launch_scripts(
dir.path(),
MacTerminalKind::Ghostty,
"/usr/local/bin/jcode",
&resolved,
)
.expect("scripts should write");
// One plan entry per resolved hotkey, each pointing at an executable bash
// script that exists on disk.
assert_eq!(plan.len(), resolved.len());
for entry in &plan {
let path = std::path::Path::new(&entry.script);
let body = std::fs::read_to_string(path).expect("script exists");
assert!(body.starts_with("#!/bin/bash"));
let mode = std::fs::metadata(path).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "script should be executable");
}
// Only the self-dev (3rd) script invokes the self-dev subcommand.
let selfdev = std::fs::read_to_string(&plan[2].script).unwrap();
assert!(selfdev.contains("self-dev"));
let home = std::fs::read_to_string(&plan[0].script).unwrap();
assert!(!home.contains("self-dev"));
}
#[test]
fn mac_hotkey_launch_agent_plist_uses_valid_xml_quotes() {
let plist = mac_hotkey_launch_agent_plist(
"/Applications/Jcode.app/Contents/MacOS/jcode",
"/tmp/jcode-hotkey.out.log",
"/tmp/jcode-hotkey.err.log",
"ghostty",
);
assert!(plist.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(plist.contains("<plist version=\"1.0\">"));
assert!(!plist.contains("\\\""));
assert!(plist.contains("<string>setup-hotkey</string>"));
assert!(plist.contains("<string>--listen-macos-hotkey</string>"));
// The listener must load into the GUI (Aqua) session so it has a
// window-server connection and can receive Carbon hotkey events.
assert!(plist.contains("<key>LimitLoadToSessionType</key>"));
assert!(plist.contains("<string>Aqua</string>"));
}
#[test]
fn paused_jcode_shell_command_keeps_failures_visible() {
let command = paused_jcode_shell_command("/tmp/jcode");
assert!(command.contains("Press Enter to close"));
assert!(command.contains("Jcode exited with status"));
assert!(command.contains("jcode executable not found"));
}
#[test]
fn fresh_user_gets_hotkey_install() {
let state = SetupHintsState::default();
assert_eq!(
mac_hotkey_action_for_state(&state),
MacHotkeyAction::Install
);
}
#[test]
fn legacy_configured_user_gets_migrated_on_update() {
// Configured before the version field existed -> version defaults to 0.
let state = SetupHintsState {
hotkey_configured: true,
hotkey_dismissed: true,
hotkey_listener_version: 0,
..SetupHintsState::default()
};
assert_eq!(
mac_hotkey_action_for_state(&state),
MacHotkeyAction::Migrate
);
}
#[test]
fn current_version_user_is_left_alone() {
let state = SetupHintsState {
hotkey_configured: true,
hotkey_dismissed: true,
hotkey_listener_version: HOTKEY_LISTENER_VERSION,
..SetupHintsState::default()
};
assert_eq!(mac_hotkey_action_for_state(&state), MacHotkeyAction::None);
}
#[test]
fn previous_listener_version_user_gets_migrated_on_update() {
// A user who already installed an earlier listener version (e.g. the v1
// run-loop-only listener that still never fired) must be re-migrated to the
// current listener on update.
for old_version in 0..HOTKEY_LISTENER_VERSION {
let state = SetupHintsState {
hotkey_configured: true,
hotkey_dismissed: true,
hotkey_listener_version: old_version,
..SetupHintsState::default()
};
assert_eq!(
mac_hotkey_action_for_state(&state),
MacHotkeyAction::Migrate,
"listener version {old_version} should be migrated"
);
}
}
#[test]
fn macos_terminal_notice_only_fires_for_default_terminal_app() {
let mut state = SetupHintsState::default();
let hints = macos_terminal_notice(&mut state, MacTerminalKind::AppleTerminal)
.expect("Terminal.app should produce a notice");
assert_eq!(
hints.status_notice.as_deref(),
Some("Tip: Terminal.app renders jcode poorly. Try Ghostty, iTerm2, or Alacritty.")
);
let (title, message) = hints.display_message.expect("expected display message");
assert_eq!(title, "Terminal");
assert!(message.contains("Terminal.app renders jcode poorly"));
assert!(message.contains("Ghostty"));
// It is a plain notice, not an AI handoff prompt.
assert!(hints.auto_send_message.is_none());
// The nudge is marked handled so it only ever shows once.
assert!(state.mac_ghostty_guided);
assert!(state.mac_ghostty_dismissed);
}
#[test]
fn macos_terminal_notice_silent_for_modern_terminals() {
for terminal in [
MacTerminalKind::Ghostty,
MacTerminalKind::Iterm2,
MacTerminalKind::WezTerm,
MacTerminalKind::Warp,
MacTerminalKind::Alacritty,
MacTerminalKind::Vscode,
MacTerminalKind::Unknown,
] {
let mut state = SetupHintsState::default();
assert!(
macos_terminal_notice(&mut state, terminal).is_none(),
"{terminal:?} should not be nudged"
);
// Even when silent, the nudge is marked handled so we never re-check it.
assert!(state.mac_ghostty_guided);
assert!(state.mac_ghostty_dismissed);
}
}
#[test]
fn nudge_budget_caps_at_max_and_persists() {
let mut state = SetupHintsState::default();
assert_eq!(state.terminal_nudge_count, 0);
for shown in 1..=MAX_TERMINAL_NUDGES {
assert!(
state.nudge_budget_remaining(),
"should still allow nudge before #{shown}"
);
state.terminal_nudge_count = shown;
}
// After MAX_TERMINAL_NUDGES, we stop asking even without an explicit dismiss.
assert_eq!(state.terminal_nudge_count, MAX_TERMINAL_NUDGES);
assert!(!state.nudge_budget_remaining());
}
#[test]
fn load_from_falls_back_to_bak_when_primary_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("setup_hints.json");
let bak = dir.path().join("setup_hints.bak");
std::fs::write(&bak, r#"{"launch_count":42}"#).unwrap();
// Primary file missing: must recover launch_count from the .bak instead of
// resetting to default (which would re-trigger first-run onboarding).
let loaded = SetupHintsState::load_from(&path);
assert_eq!(loaded.launch_count, 42);
}
#[test]
fn load_from_falls_back_to_bak_when_primary_corrupt_without_inline_recovery() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("setup_hints.json");
let bak = dir.path().join("setup_hints.bak");
std::fs::write(&path, b"{not json").unwrap();
std::fs::write(&bak, r#"{"launch_count":7}"#).unwrap();
let loaded = SetupHintsState::load_from(&path);
assert_eq!(loaded.launch_count, 7);
}
#[test]
fn load_from_defaults_when_both_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("setup_hints.json");
let loaded = SetupHintsState::load_from(&path);
assert_eq!(loaded.launch_count, 0);
}
#[test]
fn conflict_hint_decision_warns_only_when_conflicts_change() {
// No conflicts ever: empty == empty => stay silent.
assert_eq!(
conflict_hint_decision("", ""),
ConflictHintDecision::Unchanged
);
// New conflicts where there were none: warn.
assert_eq!(
conflict_hint_decision("keybindings.model_switch_next|ctrl+tab|ctrl+tab", ""),
ConflictHintDecision::Warn
);
// Same conflicts as last time: stay silent.
let sig = "keybindings.model_switch_next|ctrl+tab|ctrl+tab";
assert_eq!(
conflict_hint_decision(sig, sig),
ConflictHintDecision::Unchanged
);
// Conflicts resolved since last time (had some, now none): update silently.
assert_eq!(
conflict_hint_decision("", sig),
ConflictHintDecision::ResolvedSilently
);
// Conflict set changed (different conflicts): warn again.
assert_eq!(
conflict_hint_decision("keybindings.scroll_up|ctrl+k|ctrl+k", sig),
ConflictHintDecision::Warn
);
}
#[test]
fn keymap_conflict_hint_full_path_debounces_and_persists_signature() {
use crate::keymap::source::{DiscoveredBinding, KeySource};
use crate::keymap::{KeyChord, KeymapSnapshot};
use jcode_config_types::KeybindingsConfig;
fn snapshot(bindings: Vec<DiscoveredBinding>) -> KeymapSnapshot {
KeymapSnapshot {
version: 1,
captured_at: "0".to_string(),
os: "macos".to_string(),
terminal: "Ghostty".to_string(),
terminal_version: "1.3.1".to_string(),
bindings,
}
}
fn term(keys: &str, action: &str) -> DiscoveredBinding {
DiscoveredBinding {
chord: KeyChord::parse(keys).unwrap(),
source: KeySource::Terminal,
action: action.to_string(),
raw: format!("{keys}={action}"),
tool: String::new(),
}
}
let cfg = KeybindingsConfig::default();
let mut state = SetupHintsState::default();
// 1) First time with a real conflict: warn + state changes.
let conflicting = snapshot(vec![term("ctrl+tab", "next_tab")]);
let (hint, changed) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state);
assert!(hint.is_some(), "should warn on first conflict");
assert!(changed, "state signature should be recorded");
let (title, body) = hint.unwrap().display_message.unwrap();
assert_eq!(title, "Keybindings");
assert!(body.contains("keybindings.model_switch_next"));
assert!(!state.keymap_conflict_signature.is_empty());
// 2) Same conflict again: debounced, no state change.
let (hint2, changed2) = keymap_conflict_hint_for(&cfg, &conflicting, &mut state);
assert!(hint2.is_none(), "same conflict set must not re-warn");
assert!(!changed2, "no state change when nothing changed");
// 3) Conflict resolved (clean snapshot): silent, but signature cleared.
let clean = snapshot(vec![term("cmd+t", "new_tab")]);
let (hint3, changed3) = keymap_conflict_hint_for(&cfg, &clean, &mut state);
assert!(hint3.is_none(), "resolved conflicts show nothing");
assert!(changed3, "signature should be cleared");
assert!(state.keymap_conflict_signature.is_empty());
}
#[test]
fn glyph_safe_notice_shows_once_then_debounces() {
let mut state = SetupHintsState::default();
// First launch in a fragile terminal: disclose the tradeoff and persist.
let (hint, changed) = glyph_safe_notice_for(true, &mut state);
assert!(
hint.is_some(),
"should disclose glyph-safe mode on first launch"
);
assert!(changed, "state should be marked shown");
assert!(state.glyph_safe_notice_shown);
let (title, body) = hint.unwrap().display_message.unwrap();
assert_eq!(title, "Display");
assert!(body.contains("quantizes colors"));
assert!(body.contains("JCODE_GLYPH_SAFE_MODE=off"));
// Subsequent launches: debounced, no repeat.
let (hint2, changed2) = glyph_safe_notice_for(true, &mut state);
assert!(hint2.is_none(), "must not re-disclose on later launches");
assert!(!changed2);
}
#[test]
fn glyph_safe_notice_silent_on_robust_terminals() {
let mut state = SetupHintsState::default();
let (hint, changed) = glyph_safe_notice_for(false, &mut state);
assert!(
hint.is_none(),
"no disclosure when glyph-safe mode is inactive"
);
assert!(!changed);
assert!(!state.glyph_safe_notice_shown);
}
fn row(chord: &str, label: &str, self_dev: bool) -> LaunchHotkeyRow {
LaunchHotkeyRow {
chord: chord.to_string(),
display: keymap::KeyChord::parse(chord)
.map(|c| c.display_symbols())
.unwrap_or_else(|| chord.to_string()),
label: label.to_string(),
cwd_display: format!("/repos/{label}"),
self_dev,
}
}
#[test]
fn launch_hotkey_notice_lists_all_unlearned_bindings() {
let rows = vec![
row("cmd+;", "home", false),
row("cmd+'", "last project", false),
row("cmd+shift+'", "self-dev", true),
];
let usage = std::collections::HashMap::new();
let lines = launch_hotkey_notice_lines(&rows, &usage, 1).expect("should show all bindings");
assert_eq!(lines.len(), 3);
assert!(lines[0].starts_with("⌘; → home (/repos/home)"));
assert!(lines[2].ends_with("[self-dev]"));
}
#[test]
fn launch_hotkey_notice_hides_individually_learned_bindings() {
let rows = vec![
row("cmd+;", "home", false),
row("cmd+'", "last project", false),
];
let mut usage = std::collections::HashMap::new();
// cmd+; used enough to be considered learned; cmd+' still new.
usage.insert("cmd+;".to_string(), LAUNCH_HOTKEY_LEARNED_USES);
let lines = launch_hotkey_notice_lines(&rows, &usage, 3).expect("one binding still new");
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("⌘' → last project"));
}
#[test]
fn launch_hotkey_notice_stops_once_learned_and_experienced() {
let rows = vec![
row("cmd+;", "home", false),
row("cmd+'", "last project", false),
];
let mut usage = std::collections::HashMap::new();
usage.insert("cmd+;".to_string(), LAUNCH_HOTKEY_LEARNED_USES);
// Learned at least one binding AND launched enough overall -> stop entirely,
// even though cmd+' was never used.
assert!(
launch_hotkey_notice_lines(&rows, &usage, LAUNCH_HOTKEY_NOTICE_MIN_LAUNCHES_TO_STOP)
.is_none()
);
}
#[test]
fn launch_hotkey_notice_keeps_showing_for_new_user_with_many_launches() {
// Many launches but no binding learned yet: keep showing so they can adopt it.
let rows = vec![row("cmd+;", "home", false)];
let usage = std::collections::HashMap::new();
let lines =
launch_hotkey_notice_lines(&rows, &usage, 50).expect("never learned -> keep showing");
assert_eq!(lines.len(), 1);
}
@@ -0,0 +1,388 @@
//! Config-driven global launch hotkeys on Windows.
//!
//! Windows registers global hotkeys through the Win32 `RegisterHotKey` API. We
//! keep the existing delivery mechanism (a hidden PowerShell listener started
//! at login) but generate it from the shared `[launch_hotkeys]` config instead
//! of hard-coding a single Alt+; binding, so per-repo hotkeys work on Windows
//! the same way they do on macOS and Linux.
//!
//! This module is the **pure** layer, mirroring `linux_niri`/`linux_env`:
//!
//! * [`chord_to_win32`] maps a jcode [`KeyChord`] onto `RegisterHotKey`
//! modifier flags plus a virtual-key code. jcode's `cmd` modifier maps to
//! **Alt** on Windows (matching the historical Alt+; hotkey): most Win+ key
//! combos are reserved by the OS (Win+; opens the emoji panel), so mapping
//! cmd -> Win would produce hotkeys that never fire.
//! * [`render_windows_listener_ps1`] renders the entire listener script from
//! the resolved entries. Dynamic targets (`$LAST_DIR`/`$LAST_REPO`) are read
//! from their files at keypress time, so "last project" tracks new launches
//! without restarting the listener.
//!
//! The install glue (writing the script, startup shortcut, process restart)
//! stays in `windows_setup.rs`.
use crate::keymap::KeyChord;
/// `RegisterHotKey` modifier flags.
const MOD_ALT: u32 = 0x0001;
const MOD_CONTROL: u32 = 0x0002;
const MOD_SHIFT: u32 = 0x0004;
/// One launch hotkey resolved for the Windows listener.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WindowsHotkey {
pub chord: KeyChord,
/// Configured directory target: an absolute path or one of the sentinels
/// `$HOME`, `$LAST_DIR`, `$LAST_REPO` (resolved at keypress time).
pub dir: String,
/// Short human label, e.g. the repo's directory name.
pub label: String,
pub self_dev: bool,
}
/// Map a chord onto `(modifier_flags, virtual_key_code)` for `RegisterHotKey`.
/// Returns `None` for keys with no stable VK code. jcode `cmd` maps to Alt
/// (see module docs); a chord that is *both* cmd and alt still collapses onto
/// a single MOD_ALT, which is the closest expressible binding.
pub(crate) fn chord_to_win32(chord: &KeyChord) -> Option<(u32, u32)> {
let vk = key_to_vk(&chord.key)?;
let mut mods = 0u32;
if chord.cmd || chord.alt {
mods |= MOD_ALT;
}
if chord.ctrl {
mods |= MOD_CONTROL;
}
if chord.shift {
mods |= MOD_SHIFT;
}
if mods == 0 {
// An unmodified global hotkey would swallow plain typing; refuse it.
return None;
}
Some((mods, vk))
}
/// Translate a canonical jcode key token into a Win32 virtual-key code.
fn key_to_vk(key: &str) -> Option<u32> {
let vk = match key {
";" => 0xBA, // VK_OEM_1
"=" => 0xBB, // VK_OEM_PLUS
"," => 0xBC, // VK_OEM_COMMA
"-" => 0xBD, // VK_OEM_MINUS
"." => 0xBE, // VK_OEM_PERIOD
"/" => 0xBF, // VK_OEM_2
"`" => 0xC0, // VK_OEM_3
"[" => 0xDB, // VK_OEM_4
"\\" => 0xDC, // VK_OEM_5
"]" => 0xDD, // VK_OEM_6
"'" => 0xDE, // VK_OEM_7
"space" => 0x20,
"left" => 0x25,
"up" => 0x26,
"right" => 0x27,
"down" => 0x28,
"insert" => 0x2D,
"delete" => 0x2E,
"home" => 0x24,
"end" => 0x23,
"pageup" => 0x21,
"pagedown" => 0x22,
other => {
let mut chars = other.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if c.is_ascii_alphabetic() {
return Some(c.to_ascii_uppercase() as u32);
}
if c.is_ascii_digit() {
return Some(c as u32);
}
}
// f1..f24 -> VK_F1 (0x70) ..
if let Some(rest) = other.strip_prefix('f')
&& let Ok(n) = rest.parse::<u32>()
&& (1..=24).contains(&n)
{
return Some(0x70 + n - 1);
}
return None;
}
};
Some(vk)
}
/// User-facing chord rendering for Windows notices: jcode's `cmd` modifier
/// shows as `Alt` because that is what [`chord_to_win32`] registers.
pub(crate) fn display_windows(chord: &KeyChord) -> String {
let mut mapped = chord.clone();
if mapped.cmd {
mapped.cmd = false;
mapped.alt = true;
}
mapped.display()
}
/// Escape a string for a single-quoted PowerShell literal.
fn ps_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "''"))
}
/// PowerShell expression resolving one entry's working directory at keypress
/// time, with a `$HOME` fallback for missing/stale targets.
fn ps_dir_expr(dir: &str, last_dir_file: &str, last_repo_file: &str) -> String {
match dir {
"$HOME" => "(Resolve-JcodeDir $null)".to_string(),
"$LAST_DIR" => format!("(Resolve-JcodeDir {})", ps_quote(last_dir_file)),
"$LAST_REPO" => format!("(Resolve-JcodeDir {})", ps_quote(last_repo_file)),
path => format!("(Resolve-JcodeFixedDir {})", ps_quote(path)),
}
}
/// Render the full hotkey-listener PowerShell script.
///
/// `launch_exe`/`launch_args` describe the terminal command that opens jcode
/// (e.g. `wt.exe` + profile args, or `alacritty -e jcode`); `{DIR}` never
/// appears in them because the working directory is passed via
/// `Start-Process -WorkingDirectory`. Entries whose chord cannot be expressed
/// are skipped. Returns `None` when nothing is registerable.
pub(crate) fn render_windows_listener_ps1(
entries: &[WindowsHotkey],
launch_exe: &str,
launch_args_for: impl Fn(&WindowsHotkey) -> String,
last_dir_file: &str,
last_repo_file: &str,
) -> Option<String> {
let mut registrations = String::new();
let mut dispatch = String::new();
let mut count = 0usize;
for (index, entry) in entries.iter().enumerate() {
let Some((mods, vk)) = chord_to_win32(&entry.chord) else {
continue;
};
count += 1;
// Ids only need to be process-unique; derive from the entry index.
let id = 0x4A00 + index; // "J" namespace
let label = display_windows(&entry.chord);
registrations.push_str(&format!(
"Register-JcodeHotkey -Id 0x{id:X} -Mods 0x{mods:X} -Vk 0x{vk:X} -Label {label}\n",
label = ps_quote(&format!("{label} ({})", entry.label)),
));
let dir_expr = ps_dir_expr(&entry.dir, last_dir_file, last_repo_file);
let args = launch_args_for(entry);
let args_part = if args.is_empty() {
String::new()
} else {
format!(" -ArgumentList {}", ps_quote(&args))
};
dispatch.push_str(&format!(
" 0x{id:X} {{ Start-Process {exe}{args_part} -WorkingDirectory {dir_expr} }}\n",
exe = ps_quote(launch_exe),
));
}
if count == 0 {
return None;
}
Some(format!(
r#"# jcode global launch hotkey listener
# Auto-generated by jcode setup-hotkey from [launch_hotkeys] config. Runs at
# login via a startup shortcut. Do not edit; re-run `jcode setup-hotkey`.
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class HotKeyHelper {{
[DllImport("user32.dll")]
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll")]
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll")]
public static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[StructLayout(LayoutKind.Sequential)]
public struct MSG {{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int pt_x;
public int pt_y;
}}
}}
"@
$MOD_NOREPEAT = 0x4000
$WM_HOTKEY = 0x0312
$script:RegisteredIds = @()
function Register-JcodeHotkey {{
param([int]$Id, [uint32]$Mods, [uint32]$Vk, [string]$Label)
if ([HotKeyHelper]::RegisterHotKey([IntPtr]::Zero, $Id, $Mods -bor $MOD_NOREPEAT, $Vk)) {{
$script:RegisteredIds += $Id
}} else {{
Write-Warning "jcode: failed to register hotkey $Label (already claimed?)"
}}
}}
# Resolve a dynamic launch dir from a tracking file, falling back to $HOME.
function Resolve-JcodeDir {{
param([string]$File)
if ($File) {{
try {{
$dir = (Get-Content -LiteralPath $File -ErrorAction Stop | Select-Object -First 1).Trim()
if ($dir -and (Test-Path -LiteralPath $dir -PathType Container)) {{ return $dir }}
}} catch {{}}
}}
return $env:USERPROFILE
}}
# A baked absolute dir, falling back to $HOME when it no longer exists.
function Resolve-JcodeFixedDir {{
param([string]$Dir)
if ($Dir -and (Test-Path -LiteralPath $Dir -PathType Container)) {{ return $Dir }}
return $env:USERPROFILE
}}
{registrations}
if ($script:RegisteredIds.Count -eq 0) {{
Write-Error "jcode: no launch hotkeys could be registered"
exit 1
}}
try {{
$msg = New-Object HotKeyHelper+MSG
while ([HotKeyHelper]::GetMessage([ref]$msg, [IntPtr]::Zero, $WM_HOTKEY, $WM_HOTKEY) -ne 0) {{
if ($msg.message -ne $WM_HOTKEY) {{ continue }}
switch ($msg.wParam.ToInt32()) {{
{dispatch} }}
}}
}} finally {{
foreach ($id in $script:RegisteredIds) {{
[HotKeyHelper]::UnregisterHotKey([IntPtr]::Zero, $id) | Out-Null
}}
}}
"#
))
}
#[cfg(test)]
mod tests {
use super::*;
fn chord(s: &str) -> KeyChord {
KeyChord::parse(s).unwrap()
}
fn hk(chord_str: &str, dir: &str, label: &str, self_dev: bool) -> WindowsHotkey {
WindowsHotkey {
chord: chord(chord_str),
dir: dir.to_string(),
label: label.to_string(),
self_dev,
}
}
fn args_for(entry: &WindowsHotkey) -> String {
if entry.self_dev {
r#"-e "C:\jcode.exe" self-dev"#.to_string()
} else {
r#"-e "C:\jcode.exe""#.to_string()
}
}
#[test]
fn cmd_maps_to_alt_and_punctuation_maps_to_oem_vks() {
assert_eq!(chord_to_win32(&chord("cmd+;")).unwrap(), (MOD_ALT, 0xBA));
assert_eq!(
chord_to_win32(&chord("cmd+shift+'")).unwrap(),
(MOD_ALT | MOD_SHIFT, 0xDE)
);
assert_eq!(chord_to_win32(&chord("cmd+[")).unwrap(), (MOD_ALT, 0xDB));
assert_eq!(chord_to_win32(&chord("cmd+]")).unwrap(), (MOD_ALT, 0xDD));
assert_eq!(chord_to_win32(&chord("cmd+\\")).unwrap(), (MOD_ALT, 0xDC));
assert_eq!(
chord_to_win32(&chord("ctrl+alt+k")).unwrap(),
(MOD_ALT | MOD_CONTROL, 'K' as u32)
);
assert_eq!(chord_to_win32(&chord("alt+f5")).unwrap(), (MOD_ALT, 0x74));
}
#[test]
fn rejects_unmodified_and_unmappable_chords() {
// A bare key must never become a global hotkey.
assert!(chord_to_win32(&chord("k")).is_none());
assert!(chord_to_win32(&chord("cmd+scrolllock")).is_none());
}
#[test]
fn display_windows_renders_cmd_as_alt() {
assert_eq!(display_windows(&chord("cmd+;")), "Alt+;");
assert_eq!(display_windows(&chord("cmd+shift+'")), "Alt+Shift+'");
assert_eq!(display_windows(&chord("ctrl+k")), "Ctrl+K");
}
#[test]
fn listener_script_registers_each_entry_and_dispatches_dirs() {
let entries = vec![
hk("cmd+;", "C:\\Users\\u\\jcode", "jcode", false),
hk("cmd+'", "$HOME", "home", false),
hk("cmd+shift+'", "$LAST_REPO", "self-dev", true),
];
let script = render_windows_listener_ps1(
&entries,
"wt.exe",
args_for,
"C:\\Users\\u\\.jcode\\hotkey\\last_dir",
"C:\\Users\\u\\.jcode\\hotkey\\last_repo",
)
.unwrap();
// Three registrations with distinct ids.
assert_eq!(script.matches("Register-JcodeHotkey").count(), 3 + 1); // 3 calls + fn def
assert!(script.contains("-Id 0x4A00"));
assert!(script.contains("-Id 0x4A01"));
assert!(script.contains("-Id 0x4A02"));
// Fixed dir, home fallback, and dynamic repo file all present.
assert!(script.contains("Resolve-JcodeFixedDir 'C:\\Users\\u\\jcode'"));
assert!(script.contains("Resolve-JcodeDir $null"));
assert!(script.contains("Resolve-JcodeDir 'C:\\Users\\u\\.jcode\\hotkey\\last_repo'"));
// Self-dev entry passes the subcommand; others do not.
assert_eq!(script.matches("self-dev").count(), 2); // label + args
assert!(script.contains("Start-Process 'wt.exe'"));
assert!(script.contains("-WorkingDirectory"));
// Cleanup unregisters everything.
assert!(script.contains("UnregisterHotKey"));
}
#[test]
fn listener_script_skips_unmappable_entries() {
let entries = vec![
hk("cmd+scrolllock", "$HOME", "bad", false),
hk("cmd+;", "$HOME", "home", false),
];
let script = render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").unwrap();
assert!(
script.contains("-Id 0x4A01"),
"kept entry keeps its slot id"
);
assert!(!script.contains("-Id 0x4A00"));
}
#[test]
fn listener_script_none_when_nothing_registerable() {
let entries = vec![hk("cmd+scrolllock", "$HOME", "bad", false)];
assert!(render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").is_none());
assert!(render_windows_listener_ps1(&[], "wt.exe", args_for, "", "").is_none());
}
#[test]
fn ps_quoting_escapes_single_quotes() {
assert_eq!(ps_quote("it's"), "'it''s'");
let entries = vec![hk("cmd+;", "C:\\Users\\o'brien\\proj", "o'brien", false)];
let script = render_windows_listener_ps1(&entries, "wt.exe", args_for, "", "").unwrap();
assert!(script.contains("'C:\\Users\\o''brien\\proj'"));
}
}
@@ -0,0 +1,687 @@
use super::{SetupHintsState, StartupHints, read_choice};
use crate::windows_hotkeys::{self, WindowsHotkey};
use anyhow::Result;
use jcode_storage as storage;
use std::io::{self, Write};
fn detect_terminal() -> &'static str {
if std::env::var("WT_SESSION").is_ok() {
"windows-terminal"
} else if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
"wezterm"
} else if std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
"alacritty"
} else {
"unknown"
}
}
fn is_alacritty_installed() -> bool {
std::process::Command::new("where")
.arg("alacritty")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn is_winget_available() -> bool {
std::process::Command::new("where")
.arg("winget")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(super) fn find_alacritty_path() -> Option<String> {
let candidates = [
r"C:\Program Files\Alacritty\alacritty.exe",
r"C:\Program Files (x86)\Alacritty\alacritty.exe",
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Some(c.to_string());
}
}
if let Ok(local) = std::env::var("LOCALAPPDATA") {
let p = format!(r"{}\Microsoft\WinGet\Links\alacritty.exe", local);
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let output = std::process::Command::new("where")
.arg("alacritty")
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(line) = stdout.lines().next() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
/// Resolve the `[launch_hotkeys]` config into Windows listener entries.
/// Empty config reproduces the built-in three hotkeys, matching macOS/Linux.
fn resolve_windows_hotkeys() -> Vec<WindowsHotkey> {
let config = super::load_launch_hotkeys_config();
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "jcode".to_string());
let last_dir = super::mac_hotkey_last_dir_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let last_repo = super::mac_hotkey_last_repo_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
crate::launch_hotkeys::resolve_launch_hotkeys(&config, &exe_path, &last_dir, &last_repo)
.into_iter()
.filter_map(|entry| {
let chord = crate::keymap::KeyChord::parse(&entry.chord)?;
Some(WindowsHotkey {
chord,
dir: entry.dir,
self_dev: entry.args.iter().any(|a| a == "self-dev"),
label: entry.label,
})
})
.collect()
}
fn create_hotkey_shortcut(use_alacritty: bool) -> Result<()> {
let exe = std::env::current_exe()?;
let exe_path = exe.to_string_lossy();
let entries = resolve_windows_hotkeys();
let last_dir = super::mac_hotkey_last_dir_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let last_repo = super::mac_hotkey_last_repo_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let (launch_exe, launch_args_for): (String, Box<dyn Fn(&WindowsHotkey) -> String>) =
if use_alacritty {
let alacritty_path = find_alacritty_path().unwrap_or_else(|| "alacritty".to_string());
let exe = exe_path.to_string();
(
alacritty_path,
Box::new(move |hk: &WindowsHotkey| {
if hk.self_dev {
format!("-e \"{exe}\" self-dev")
} else {
format!("-e \"{exe}\"")
}
}),
)
} else {
let exe = exe_path.to_string();
(
"wt.exe".to_string(),
Box::new(move |hk: &WindowsHotkey| {
if hk.self_dev {
format!("-p \"Command Prompt\" \"{exe}\" self-dev")
} else {
format!("-p \"Command Prompt\" \"{exe}\"")
}
}),
)
};
let Some(ps1_content) = windows_hotkeys::render_windows_listener_ps1(
&entries,
&launch_exe,
|hk| launch_args_for(hk),
&last_dir,
&last_repo,
) else {
anyhow::bail!("no registerable launch hotkeys in config");
};
let hotkey_dir = storage::jcode_dir()?.join("hotkey");
std::fs::create_dir_all(&hotkey_dir)?;
let _ = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"Get-Process powershell, pwsh -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*jcode-hotkey*' } | Stop-Process -Force -ErrorAction SilentlyContinue",
])
.output();
let ps1_path = hotkey_dir.join("jcode-hotkey.ps1");
std::fs::write(&ps1_path, &ps1_content)?;
let startup_dir = format!(
"{}\\Microsoft\\Windows\\Start Menu\\Programs\\Startup",
std::env::var("APPDATA").unwrap_or_else(|_| "C:\\Users\\Default\\AppData\\Roaming".into())
);
let vbs_path = hotkey_dir.join("jcode-hotkey-launcher.vbs");
let vbs_content = format!(
"Set objShell = CreateObject(\"WScript.Shell\")\nobjShell.Run \"powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"\"{}\"\"\", 0, False\n",
ps1_path.to_string_lossy()
);
std::fs::write(&vbs_path, &vbs_content)?;
let create_startup_lnk = format!(
r#"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut("{startup_dir}\jcode-hotkey.lnk")
$shortcut.TargetPath = "wscript.exe"
$shortcut.Arguments = '"{vbs_path}"'
$shortcut.Description = "jcode Alt+; hotkey listener"
$shortcut.WindowStyle = 7
$shortcut.Save()
Write-Output "OK"
"#,
startup_dir = startup_dir,
vbs_path = vbs_path.to_string_lossy(),
);
let output = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &create_startup_lnk])
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to create startup shortcut: {}", stderr.trim());
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("OK") {
anyhow::bail!("Startup shortcut creation did not confirm success");
}
let start_output = std::process::Command::new("powershell")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-Command",
&format!(
"Start-Process wscript.exe -ArgumentList '\"{}\"' -WindowStyle Hidden",
vbs_path.to_string_lossy()
),
])
.output();
if let Err(e) = start_output {
eprintln!(
" \x1b[33m⚠\x1b[0m Could not start hotkey listener now: {}",
e
);
eprintln!(" It will start automatically on next login.");
}
Ok(())
}
/// Build the TUI startup notice for the Windows launch hotkeys (or `None` when
/// there is nothing to show). Mirrors the macOS/Linux notices with Alt-style
/// chords. Only shown once the listener is configured, since Windows needs the
/// interactive `jcode setup-hotkey` flow to install it.
pub(super) fn windows_launch_hotkeys_notice(state: &SetupHintsState) -> Option<StartupHints> {
if !state.hotkey_configured {
return None;
}
let config = super::load_launch_hotkeys_config();
if config.enabled == Some(false) {
return None;
}
let last_dir = super::mac_hotkey_last_dir_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let last_repo = super::mac_hotkey_last_repo_file()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let rows: Vec<super::LaunchHotkeyRow> = resolve_windows_hotkeys()
.into_iter()
.filter(|hk| windows_hotkeys::chord_to_win32(&hk.chord).is_some())
.map(|hk| {
let cwd = crate::launch_hotkeys::resolve_target_dir(&hk.dir, &last_dir, &last_repo);
super::LaunchHotkeyRow {
chord: hk.chord.canonical(),
display: windows_hotkeys::display_windows(&hk.chord),
label: hk.label.clone(),
cwd_display: cwd.display().to_string(),
self_dev: hk.self_dev,
}
})
.collect();
let lines =
super::launch_hotkey_notice_lines(&rows, &state.launch_hotkey_usage, state.launch_count)?;
Some(StartupHints::with_status_and_display(
"Launch hotkeys available".to_string(),
"Launch hotkeys",
format!(
"Configured Jcode launch hotkeys:\n{}\n\nThese fire system-wide.",
lines.join("\n")
),
))
}
/// Reinstall the Windows hotkey listener after the `[launch_hotkeys]` config
/// changed. No-op unless the user already configured the hotkey (we never
/// install behind someone who opted out). Best-effort.
pub(super) fn reinstall_windows_launch_hotkeys() {
let state = SetupHintsState::load();
if !state.hotkey_configured {
return;
}
let use_alacritty = detect_terminal() == "alacritty" || is_alacritty_installed();
match create_hotkey_shortcut(use_alacritty) {
Ok(()) => jcode_logging::info("Reinstalled Windows launch hotkeys after config change"),
Err(err) => jcode_logging::warn(&format!(
"failed to reinstall Windows launch hotkeys: {err}"
)),
}
}
fn install_alacritty() -> Result<()> {
eprintln!(" Installing Alacritty via winget...");
eprintln!(" (Windows may ask for permission to install)\n");
let status = std::process::Command::new("winget")
.args([
"install",
"-e",
"--id",
"Alacritty.Alacritty",
"--accept-source-agreements",
])
.status()?;
if status.success() {
Ok(())
} else {
anyhow::bail!("winget install failed (exit code: {:?})", status.code())
}
}
fn nudge_hotkey(state: &mut SetupHintsState) -> bool {
let terminal = detect_terminal();
let using_alacritty = terminal == "alacritty" || is_alacritty_installed();
let terminal_name = if using_alacritty {
"Alacritty"
} else {
"Windows Terminal"
};
eprintln!("\x1b[36m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
eprintln!(
"\x1b[36m│\x1b[0m \x1b[1m💡 Set up Alt+; to launch jcode from anywhere?\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m Creates a global hotkey - no extra software needed. \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m Opens jcode in {:<39} \x1b[36m│\x1b[0m",
format!("{}.", terminal_name)
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[32m[y]\x1b[0m Set up \x1b[90m[n]\x1b[0m Not now \x1b[90m[d]\x1b[0m Don't ask again \x1b[36m│\x1b[0m"
);
eprintln!("\x1b[36m└─────────────────────────────────────────────────────────────┘\x1b[0m");
eprint!("\x1b[36m >\x1b[0m ");
let _ = io::stderr().flush();
let choice = read_choice();
match choice.as_str() {
"y" | "yes" => {
eprint!("\n");
match create_hotkey_shortcut(using_alacritty) {
Ok(()) => {
state.hotkey_configured = true;
let _ = state.save();
eprintln!(
" \x1b[32m✓\x1b[0m Created hotkey (\x1b[1mAlt+;\x1b[0m) → {} + jcode",
terminal_name
);
eprintln!();
true
}
Err(e) => {
eprintln!(" \x1b[31m✗\x1b[0m Failed to create hotkey: {}", e);
eprintln!(
" You can set it up manually later with: \x1b[1mjcode setup-hotkey\x1b[0m"
);
eprintln!();
false
}
}
}
"d" | "dont" => {
state.hotkey_dismissed = true;
let _ = state.save();
false
}
_ => false,
}
}
fn nudge_alacritty(state: &mut SetupHintsState) -> bool {
let terminal = detect_terminal();
let current_terminal = match terminal {
"windows-terminal" => "Windows Terminal",
"wezterm" => "WezTerm",
_ => "your current terminal",
};
eprintln!("\x1b[36m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
eprintln!(
"\x1b[36m│\x1b[0m \x1b[1m💡 Alacritty: the fastest terminal for jcode\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m {:<55} \x1b[36m│\x1b[0m",
format!("You're using {}.", current_terminal)
);
eprintln!(
"\x1b[36m│\x1b[0m Alacritty is GPU-accelerated with the lowest latency. \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m"
);
eprintln!(
"\x1b[36m│\x1b[0m \x1b[32m[y]\x1b[0m Install \x1b[90m[n]\x1b[0m Not now \x1b[90m[d]\x1b[0m Don't ask again \x1b[36m│\x1b[0m"
);
eprintln!("\x1b[36m└─────────────────────────────────────────────────────────────┘\x1b[0m");
eprint!("\x1b[36m >\x1b[0m ");
let _ = io::stderr().flush();
let choice = read_choice();
match choice.as_str() {
"y" | "yes" => {
eprint!("\n");
if !is_winget_available() {
eprintln!(" \x1b[33m⚠\x1b[0m winget not found. Install Alacritty manually:");
eprintln!(" https://alacritty.org/");
eprintln!();
eprintln!(" Or install winget first: https://aka.ms/getwinget");
eprintln!();
return false;
}
match install_alacritty() {
Ok(()) => {
state.alacritty_configured = true;
let _ = state.save();
eprintln!(" \x1b[32m✓\x1b[0m Alacritty installed!");
if state.hotkey_configured {
eprintln!(" Updating hotkey to use Alacritty...");
match create_hotkey_shortcut(true) {
Ok(()) => {
eprintln!(
" \x1b[32m✓\x1b[0m Hotkey updated: \x1b[1mAlt+;\x1b[0m → Alacritty + jcode"
);
}
Err(e) => {
eprintln!(" \x1b[33m⚠\x1b[0m Could not update hotkey: {}", e);
}
}
}
eprintln!();
true
}
Err(e) => {
eprintln!(" \x1b[31m✗\x1b[0m Failed to install Alacritty: {}", e);
eprintln!(" Install manually: https://alacritty.org/");
eprintln!();
false
}
}
}
"d" | "dont" => {
state.alacritty_dismissed = true;
let _ = state.save();
false
}
_ => false,
}
}
fn prompt_try_it_out(installed_alacritty: bool) {
eprintln!("\x1b[32m┌─────────────────────────────────────────────────────────────┐\x1b[0m");
eprintln!(
"\x1b[32m│\x1b[0m \x1b[1m✨ All set! Try it out:\x1b[0m \x1b[32m│\x1b[0m"
);
eprintln!(
"\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m"
);
eprintln!(
"\x1b[32m│\x1b[0m Press \x1b[1mAlt+;\x1b[0m from anywhere to launch jcode. \x1b[32m│\x1b[0m"
);
eprintln!(
"\x1b[32m│\x1b[0m Inside jcode, \x1b[1mAlt+Shift+;\x1b[0m opens a new session here. \x1b[32m│\x1b[0m"
);
if installed_alacritty {
eprintln!(
"\x1b[32m│\x1b[0m It will open in \x1b[1mAlacritty\x1b[0m for maximum performance. \x1b[32m│\x1b[0m"
);
}
eprintln!(
"\x1b[32m│\x1b[0m \x1b[32m│\x1b[0m"
);
eprintln!(
"\x1b[32m│\x1b[0m \x1b[90m(Starting jcode normally in 3 seconds...)\x1b[0m \x1b[32m│\x1b[0m"
);
eprintln!("\x1b[32m└─────────────────────────────────────────────────────────────┘\x1b[0m");
eprintln!();
std::thread::sleep(std::time::Duration::from_secs(3));
}
pub(super) fn maybe_show_windows_setup_hints(
state: &mut SetupHintsState,
startup_hints: Option<StartupHints>,
) -> Option<StartupHints> {
if state.launch_count % 3 != 0 {
return startup_hints;
}
let terminal = detect_terminal();
let already_using_alacritty = terminal == "alacritty";
if already_using_alacritty {
state.alacritty_configured = true;
state.alacritty_dismissed = true;
let _ = state.save();
}
let wants_hotkey_nudge = !state.hotkey_configured && !state.hotkey_dismissed;
let wants_alacritty_nudge =
!state.alacritty_configured && !state.alacritty_dismissed && !already_using_alacritty;
// Stop pestering the user once we have shown the nudge prompt enough times,
// even if they never explicitly chose "Don't ask again".
if (wants_hotkey_nudge || wants_alacritty_nudge) && !state.nudge_budget_remaining() {
return startup_hints;
}
let mut did_setup_hotkey = false;
let mut did_install_alacritty = false;
if wants_hotkey_nudge {
state.record_nudge_shown();
did_setup_hotkey = nudge_hotkey(state);
}
if wants_alacritty_nudge {
state.record_nudge_shown();
did_install_alacritty = nudge_alacritty(state);
}
if did_setup_hotkey || (did_install_alacritty && state.hotkey_configured) {
prompt_try_it_out(did_install_alacritty);
}
startup_hints
}
pub(super) fn run_setup_hotkey_windows() -> Result<()> {
let mut state = SetupHintsState::load();
let terminal = detect_terminal();
let already_using_alacritty = terminal == "alacritty";
eprintln!("\x1b[1mjcode setup-hotkey\x1b[0m");
eprintln!();
eprintln!(
" Detected terminal: {}",
match terminal {
"windows-terminal" => "Windows Terminal",
"wezterm" => "WezTerm",
"alacritty" => "Alacritty",
_ => "Unknown",
}
);
if is_alacritty_installed() && !already_using_alacritty {
eprintln!(" Alacritty: \x1b[32minstalled\x1b[0m");
} else if already_using_alacritty {
eprintln!(" Alacritty: \x1b[32mactive\x1b[0m");
} else {
eprintln!(" Alacritty: \x1b[90mnot installed\x1b[0m");
}
eprintln!();
let mut installed_alacritty = false;
if !already_using_alacritty && !is_alacritty_installed() {
eprintln!(
" Alacritty is the fastest terminal emulator (GPU-accelerated, lowest latency)."
);
eprint!(" Install Alacritty? \x1b[32m[y]\x1b[0m/\x1b[90m[n]\x1b[0m: ");
let _ = io::stderr().flush();
let choice = read_choice();
if choice == "y" || choice == "yes" {
if !is_winget_available() {
eprintln!("\n \x1b[33m⚠\x1b[0m winget not found. Install Alacritty manually:");
eprintln!(" https://alacritty.org/\n");
} else {
match install_alacritty() {
Ok(()) => {
state.alacritty_configured = true;
installed_alacritty = true;
eprintln!(" \x1b[32m✓\x1b[0m Alacritty installed!\n");
}
Err(e) => {
eprintln!(" \x1b[31m✗\x1b[0m Install failed: {}\n", e);
}
}
}
}
eprintln!();
}
let use_alacritty = already_using_alacritty || is_alacritty_installed();
let terminal_name = if use_alacritty {
"Alacritty"
} else {
"Windows Terminal"
};
eprintln!(
" Setting up global launch hotkeys → {} + jcode...",
terminal_name
);
match create_hotkey_shortcut(use_alacritty) {
Ok(()) => {
state.hotkey_configured = true;
let _ = state.save();
eprintln!(" \x1b[32m✓\x1b[0m Created launch hotkeys");
eprintln!();
eprintln!(" Press these anywhere, system-wide:");
for hk in resolve_windows_hotkeys() {
if windows_hotkeys::chord_to_win32(&hk.chord).is_some() {
let suffix = if hk.self_dev { " [self-dev]" } else { "" };
eprintln!(
" \x1b[1m{}\x1b[0m → {}{}",
windows_hotkeys::display_windows(&hk.chord),
hk.label,
suffix
);
}
}
eprintln!();
prompt_try_it_out(installed_alacritty);
}
Err(e) => {
eprintln!(" \x1b[31m✗\x1b[0m Failed: {}", e);
}
}
Ok(())
}
pub(super) fn create_windows_desktop_shortcut(state: &mut SetupHintsState) -> Result<()> {
let exe = std::env::current_exe()?;
let exe_path = exe.to_string_lossy();
let (target, args) = if is_alacritty_installed() {
let alacritty = find_alacritty_path().unwrap_or_else(|| "alacritty".to_string());
(alacritty, format!("-e \"{}\"", exe_path))
} else {
(exe_path.to_string(), String::new())
};
let desktop_dir = std::env::var("USERPROFILE").unwrap_or_else(|_| "C:\\Users\\Default".into());
let shortcut_path = format!("{}\\Desktop\\jcode.lnk", desktop_dir);
let ps_script = format!(
r#"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut("{shortcut_path}")
$shortcut.TargetPath = "{target}"
$shortcut.Arguments = '{args}'
$shortcut.Description = "jcode - AI coding agent"
$shortcut.Save()
Write-Output "OK"
"#,
shortcut_path = shortcut_path,
target = target,
args = args,
);
let output = std::process::Command::new("powershell")
.args(["-NoProfile", "-Command", &ps_script])
.output()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("OK") {
state.desktop_shortcut_created = true;
let _ = state.save();
jcode_logging::info(&format!("Created desktop shortcut: {}", shortcut_path));
}
}
Ok(())
}