From 9f8ce5319b18b74615ef6c545ba783e1a9132ef0 Mon Sep 17 00:00:00 2001 From: "MVB.Mir" Date: Sat, 18 Apr 2026 01:16:48 +0300 Subject: [PATCH 1/2] feat: per-tab Claude session resume Give each terminal tab its own Claude session that auto-resumes on next launch, including `/resume` switches made from inside Claude's UI. Mechanics: - Each tab gets a pre-generated session UUID plus env vars `LIMUX_TAB_ID` and `LIMUX_CLAUDE_SESSION_ID`. - A shim `claude` wrapper is installed to `~/.local/share/limux/bin/` and prepended to PATH. Plain `claude` invocations get `--session-id $LIMUX_CLAUDE_SESSION_ID` appended; `--resume`, `--continue`, `--session-id`, etc. pass through untouched. - A 1 Hz GTK poller locates the tab's `claude` process via `/proc//environ` and reads Claude's own `~/.claude/sessions/.json` status file to track the currently active session UUID (updates on `/resume`). - Snapshot persists the live UUID (falling back to the pre-generated one); restore launches `claude --resume ` if the session JSONL still exists, otherwise boots a plain shell. Two tabs in the same cwd now resume independently because each claude process has a unique PID, inherits a unique tab ID, and writes its own status file. --- rust/limux-host-linux/src/claude_session.rs | 297 ++++++++++++++++++++ rust/limux-host-linux/src/layout_state.rs | 25 +- rust/limux-host-linux/src/main.rs | 8 + rust/limux-host-linux/src/pane.rs | 112 +++++++- rust/limux-host-linux/src/terminal.rs | 73 +++++ 5 files changed, 500 insertions(+), 15 deletions(-) create mode 100644 rust/limux-host-linux/src/claude_session.rs diff --git a/rust/limux-host-linux/src/claude_session.rs b/rust/limux-host-linux/src/claude_session.rs new file mode 100644 index 00000000..c07c1e5a --- /dev/null +++ b/rust/limux-host-linux/src/claude_session.rs @@ -0,0 +1,297 @@ +//! Per-tab Claude session resume support. +//! +//! We assign each terminal tab a stable session UUID and force the `claude` +//! CLI to use it via `--session-id `. This makes session identity +//! deterministic and per-tab even when several tabs share a working +//! directory, and removes the need to scan `/proc` or guess from filesystem +//! mtimes. +//! +//! The mechanism is a thin wrapper script placed in a limux-owned directory +//! that we prepend to the shell's `PATH`. The wrapper forwards every +//! invocation of `claude` to the real binary, injecting `--session-id +//! $LIMUX_CLAUDE_SESSION_ID` when the user hasn't already asked for a +//! specific session (via `--resume`, `--continue`, or an explicit +//! `--session-id`). + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +/// Directory where the wrapper script is installed. Kept under +/// `$XDG_DATA_HOME` so it lives alongside limux's other persistent state. +pub fn wrapper_bin_dir() -> Option { + let base = dirs::data_dir().or_else(dirs::home_dir)?; + Some(if base.ends_with(".local/share") { + base.join("limux").join("bin") + } else { + base.join(".local/share").join("limux").join("bin") + }) +} + +/// Idempotently install the `claude` shim. Safe to call on every launch: the +/// script is only rewritten if its contents differ from the expected body. +pub fn ensure_wrapper_script() -> io::Result { + let dir = wrapper_bin_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "no data directory available for limux wrapper", + ) + })?; + fs::create_dir_all(&dir)?; + let script = dir.join("claude"); + let body = wrapper_script_body(); + let up_to_date = fs::read_to_string(&script) + .map(|existing| existing == body) + .unwrap_or(false); + if !up_to_date { + fs::write(&script, body)?; + fs::set_permissions(&script, fs::Permissions::from_mode(0o755))?; + } + Ok(script) +} + +/// Shell script body for the wrapper. POSIX-compatible so it works under +/// bash, zsh, dash, and fish's sh-exec fallback. Keeping it self-contained +/// in source makes updates trivial and avoids shipping a separate asset. +fn wrapper_script_body() -> String { + // `IFS=:` lets us split PATH without invoking an external `tr`. The loop + // strips our own directory so `command -v claude` never points back at + // the wrapper itself, which would cause infinite recursion. + r#"#!/bin/sh +# Installed by limux. Forces per-tab Claude session IDs via $LIMUX_CLAUDE_SESSION_ID. +# Do not edit; regenerated on every limux launch. + +self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" >/dev/null 2>&1 && pwd -P) + +# Drop our shim directory from PATH before resolving the real `claude`. +cleaned_path="" +IFS=: +for dir in $PATH; do + [ "$dir" = "$self_dir" ] && continue + if [ -n "$cleaned_path" ]; then + cleaned_path="$cleaned_path:$dir" + else + cleaned_path="$dir" + fi +done +unset IFS + +real_claude=$(PATH="$cleaned_path" command -v claude 2>/dev/null) +if [ -z "$real_claude" ]; then + printf 'limux: real claude binary not found on PATH\n' >&2 + exit 127 +fi + +# If the user already specified session intent, don't interfere. +for arg in "$@"; do + case "$arg" in + -c|--continue|--resume|--resume=*|--session-id|--session-id=*|--from-pr|--from-pr=*|--fork-session) + exec "$real_claude" "$@" + ;; + esac +done + +if [ -n "$LIMUX_CLAUDE_SESSION_ID" ]; then + exec "$real_claude" --session-id "$LIMUX_CLAUDE_SESSION_ID" "$@" +fi + +exec "$real_claude" "$@" +"# + .to_string() +} + +/// Build the shell command that resumes a specific Claude session. +/// Invoked by the restore path to spawn claude as the tab's initial command. +pub fn resume_command(session_id: &str) -> String { + format!("claude --resume {session_id}") +} + +/// Verify that a session JSONL still exists on disk before trying to resume +/// it. Returns the expected path if present, `None` otherwise. A missing +/// file means the restore path should fall back to a plain shell instead of +/// launching claude against a stale UUID. +pub fn session_file_exists(cwd: &str, session_id: &str) -> bool { + let Some(home) = dirs::home_dir() else { + return false; + }; + let dir = home.join(".claude").join("projects").join(encode_cwd(cwd)); + dir.join(format!("{session_id}.jsonl")).exists() +} + +fn encode_cwd(cwd: &str) -> String { + cwd.replace('/', "-") +} + +/// Generate a fresh v4 UUID suitable for `--session-id`. +pub fn new_session_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Return the session UUID currently active in the tab tagged with +/// `tab_id`, by locating its `claude` process and reading the +/// `~/.claude/sessions/.json` status file Claude itself maintains. +/// +/// Returns `None` when no `claude` is running in the tab, the status file +/// is missing, or the payload cannot be parsed. Claude rewrites this file +/// whenever the session changes (including after `/resume` inside the +/// interactive UI), so polling it gives us up-to-date session identity +/// without scraping JSONLs or parsing process open files. +pub fn detect_active_session_for_tab(tab_id: &str) -> Option { + let claude_pid = find_tab_claude_pid(tab_id)?; + read_claude_session_pid_file(claude_pid) +} + +fn find_tab_claude_pid(tab_id: &str) -> Option { + let needle = format!("LIMUX_TAB_ID={tab_id}"); + let entries = fs::read_dir("/proc").ok()?; + for entry in entries.flatten() { + let Some(name) = entry + .file_name() + .to_str() + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + let comm_path = format!("/proc/{name}/comm"); + let Ok(comm) = fs::read_to_string(&comm_path) else { + continue; + }; + if comm.trim() != "claude" { + continue; + } + let env_path = format!("/proc/{name}/environ"); + let Ok(env_bytes) = fs::read(env_path) else { + continue; + }; + if env_bytes + .split(|byte| *byte == 0) + .any(|entry| entry == needle.as_bytes()) + { + return Some(name); + } + } + None +} + +fn read_claude_session_pid_file(pid: u32) -> Option { + let home = dirs::home_dir()?; + let path = home + .join(".claude") + .join("sessions") + .join(format!("{pid}.json")); + let raw = fs::read_to_string(path).ok()?; + // Minimal JSON field extraction: the file is tiny and structurally fixed + // by Claude, so a bespoke scan avoids pulling in a full JSON parser just + // for one string field. Falls through to `None` on any surprise. + extract_string_field(&raw, "sessionId") +} + +fn extract_string_field(json: &str, field: &str) -> Option { + // Look for `""` followed by `:` and a `"..."` value. Handles the + // canonical shape Claude writes: `{"pid":123,"sessionId":"...","...":...}`. + let key = format!("\"{field}\""); + let start = json.find(&key)?; + let after_key = &json[start + key.len()..]; + let colon = after_key.find(':')?; + let value_start = after_key[colon + 1..].find('"')?; + let value_region = &after_key[colon + 1 + value_start + 1..]; + let value_end = value_region.find('"')?; + Some(value_region[..value_end].to_string()) +} + +/// Return the absolute path to the wrapper directory as an `OsString` +/// suitable for prepending to `PATH`. `None` if the directory cannot be +/// determined (missing `$HOME`). +pub fn wrapper_path_component() -> Option { + wrapper_bin_dir() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn encode_cwd_replaces_slashes() { + assert_eq!(encode_cwd("/home/me/projects/foo"), "-home-me-projects-foo"); + assert_eq!(encode_cwd(""), ""); + } + + #[test] + fn resume_command_matches_expected_format() { + assert_eq!( + resume_command("575027f5-543a-47c9-a449-cd2704e0c12c"), + "claude --resume 575027f5-543a-47c9-a449-cd2704e0c12c" + ); + } + + #[test] + fn wrapper_script_contains_key_logic() { + let body = wrapper_script_body(); + assert!(body.starts_with("#!/bin/sh")); + assert!(body.contains("LIMUX_CLAUDE_SESSION_ID")); + assert!(body.contains("--session-id")); + assert!(body.contains("--resume")); + } + + #[test] + fn ensure_wrapper_script_writes_executable_file() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("claude"); + fs::write(&script, "stale").expect("seed stale"); + // Hand-call the body-write path directly to avoid touching user state. + let body = wrapper_script_body(); + fs::write(&script, &body).expect("write"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("chmod"); + let meta = fs::metadata(&script).expect("metadata"); + assert_eq!(meta.permissions().mode() & 0o777, 0o755); + assert_eq!(fs::read_to_string(&script).unwrap(), body); + } + + #[test] + fn new_session_id_is_unique_uuid_format() { + let a = new_session_id(); + let b = new_session_id(); + assert_ne!(a, b); + assert_eq!(a.len(), 36); + } + + #[test] + fn session_file_exists_false_when_missing() { + assert!(!session_file_exists( + "/nonexistent/path/for/limux/test", + "00000000-0000-0000-0000-000000000000" + )); + } + + #[test] + fn extract_string_field_reads_session_id_from_claude_payload() { + let raw = + r#"{"pid":687361,"sessionId":"75c921ee-b7ea-4fb6-992b-e6f99d7e5dcc","cwd":"/home/me"}"#; + assert_eq!( + extract_string_field(raw, "sessionId").as_deref(), + Some("75c921ee-b7ea-4fb6-992b-e6f99d7e5dcc") + ); + } + + #[test] + fn extract_string_field_returns_none_for_missing_key() { + let raw = r#"{"pid":1,"cwd":"/"}"#; + assert!(extract_string_field(raw, "sessionId").is_none()); + } + + #[test] + fn extract_string_field_handles_extra_whitespace() { + let raw = r#"{ "sessionId" : "abc" }"#; + assert_eq!( + extract_string_field(raw, "sessionId").as_deref(), + Some("abc") + ); + } + + #[test] + fn detect_active_session_returns_none_when_tab_missing() { + assert!(detect_active_session_for_tab("limux-test-no-such-tab-id").is_none()); + } +} diff --git a/rust/limux-host-linux/src/layout_state.rs b/rust/limux-host-linux/src/layout_state.rs index 776ce5cf..fb451717 100644 --- a/rust/limux-host-linux/src/layout_state.rs +++ b/rust/limux-host-linux/src/layout_state.rs @@ -106,6 +106,10 @@ pub enum TabContentState { Terminal { #[serde(default)] cwd: Option, + // Session UUID of the most-recently-active `claude` conversation in `cwd` + // when this tab was last snapshotted. Used on restore to auto-`claude --resume`. + #[serde(default, skip_serializing_if = "Option::is_none")] + claude_session: Option, }, Browser { #[serde(default)] @@ -166,12 +170,21 @@ impl PaneState { impl TabState { pub fn terminal(id: impl Into, cwd: Option<&str>) -> Self { + Self::terminal_with_session(id, cwd, None) + } + + pub fn terminal_with_session( + id: impl Into, + cwd: Option<&str>, + claude_session: Option, + ) -> Self { Self { id: id.into(), custom_name: None, pinned: false, content: TabContentState::Terminal { cwd: cwd.map(|value| value.to_string()), + claude_session, }, } } @@ -520,8 +533,12 @@ mod tests { }; assert_eq!(pane.tabs.len(), 1); match &pane.tabs[0].content { - TabContentState::Terminal { cwd } => { + TabContentState::Terminal { + cwd, + claude_session, + } => { assert_eq!(cwd.as_deref(), Some("/tmp/project")); + assert!(claude_session.is_none()); } other => panic!("expected terminal tab, got {other:?}"), } @@ -619,8 +636,12 @@ mod tests { }; assert_eq!(pane.tabs.len(), 1); match &pane.tabs[0].content { - TabContentState::Terminal { cwd } => { + TabContentState::Terminal { + cwd, + claude_session, + } => { assert_eq!(cwd.as_deref(), Some("/tmp/project")); + assert!(claude_session.is_none()); } other => panic!("expected terminal fallback, got {other:?}"), } diff --git a/rust/limux-host-linux/src/main.rs b/rust/limux-host-linux/src/main.rs index 41296fd7..675e1cdc 100644 --- a/rust/limux-host-linux/src/main.rs +++ b/rust/limux-host-linux/src/main.rs @@ -1,4 +1,5 @@ mod app_config; +mod claude_session; mod control_bridge; mod ghostty_config; mod keybind_editor; @@ -139,6 +140,13 @@ fn main() { // fall back to common system Ghostty install locations. set_ghostty_runtime_env(); + // Install the `claude` wrapper script that intercepts invocations inside + // limux-spawned shells and pins them to a stable per-tab session UUID. + // Non-fatal on failure: Claude auto-resume simply won't activate. + if let Err(err) = claude_session::ensure_wrapper_script() { + eprintln!("limux: failed to install claude wrapper: {err}"); + } + // WebKitGTK's bubblewrap sandbox requires unprivileged user namespaces, // which may not be available. Disable it to prevent crashes on launch. if std::env::var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS").is_err() { diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 45d83fd9..e72ff51a 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -190,6 +190,20 @@ pub struct PaneCallbacks { struct TerminalTabState { cwd: Rc>>, handle: terminal::TerminalHandle, + // Stable per-tab Claude session UUID. Either (a) loaded from persisted + // state on restore, or (b) freshly generated when the tab is created. + // The shell inherits this value as `LIMUX_CLAUDE_SESSION_ID`; the + // bundled wrapper script forces fresh `claude` invocations to use it. + // + // This is the *default* session id. It can be overridden at runtime if + // the user opens a different conversation via `/resume` inside Claude — + // see `live_claude_session` below, which the background poller keeps in + // sync with Claude's own `~/.claude/sessions/.json` status file. + claude_session_id: String, + // Most recent session UUID observed in the claude process attached to + // this tab. Sticky: we hold onto the last value even after claude exits + // so the next snapshot still persists the right session to resume. + live_claude_session: Rc>>, } #[derive(Clone)] @@ -767,6 +781,26 @@ fn next_tab_id() -> String { uuid::Uuid::new_v4().to_string() } +/// Watch the `claude` process attached to `tab_id` and mirror its current +/// session UUID into `live_session`. Runs on the GTK main loop at 1 Hz and +/// self-terminates once the tab's strong `Rc` is dropped (i.e. the tab is +/// closed), via a weak reference upgrade check. +fn spawn_claude_session_poller(tab_id: String, live_session: &Rc>>) { + let weak = Rc::downgrade(live_session); + glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + let Some(cell) = weak.upgrade() else { + return glib::ControlFlow::Break; + }; + if let Some(uuid) = crate::claude_session::detect_active_session_for_tab(&tab_id) { + let mut slot = cell.borrow_mut(); + if slot.as_deref() != Some(uuid.as_str()) { + *slot = Some(uuid); + } + } + glib::ControlFlow::Continue + }); +} + // --------------------------------------------------------------------------- // Icon button helper // --------------------------------------------------------------------------- @@ -827,6 +861,12 @@ struct TerminalTabOptions<'a> { custom_name: Option<&'a str>, pinned: bool, cwd: Option<&'a str>, + // When `Some`, Ghostty spawns this command instead of the user's shell. + // Used to auto-resume a Claude session when restoring from persisted state. + startup_command: Option, + // Persisted Claude session UUID for this tab. `None` on fresh tabs; + // `Some` when the tab is being restored from `session.json`. + claude_session_id: Option, } struct BrowserTabOptions<'a> { @@ -860,16 +900,33 @@ fn restore_tabs_from_state( for saved_tab in &saved_state.tabs { match &saved_tab.content { - TabContentState::Terminal { cwd } => add_terminal_tab_inner( - internals, - cwd.as_deref().or(working_directory), - Some(TerminalTabOptions { - id: Some(saved_tab.id.as_str()), - custom_name: saved_tab.custom_name.as_deref(), - pinned: saved_tab.pinned, - cwd: cwd.as_deref().or(working_directory), - }), - ), + TabContentState::Terminal { + cwd, + claude_session, + } => { + let effective_cwd = cwd.as_deref().or(working_directory); + // Re-resume only when the session JSONL is still on disk — + // otherwise we'd drop the user at a hang/error. `claude_session` + // stays on the tab either way so the next quit re-persists it. + let startup_command = claude_session.as_deref().and_then(|uuid| { + effective_cwd.and_then(|cwd| { + crate::claude_session::session_file_exists(cwd, uuid) + .then(|| crate::claude_session::resume_command(uuid)) + }) + }); + add_terminal_tab_inner( + internals, + effective_cwd, + Some(TerminalTabOptions { + id: Some(saved_tab.id.as_str()), + custom_name: saved_tab.custom_name.as_deref(), + pinned: saved_tab.pinned, + cwd: effective_cwd, + startup_command, + claude_session_id: claude_session.clone(), + }), + ); + } TabContentState::Browser { uri } => add_browser_tab_inner( internals, Some(BrowserTabOptions { @@ -1062,17 +1119,30 @@ fn add_terminal_tab_inner( }) }; + let startup_command = options + .as_ref() + .and_then(|value| value.startup_command.clone()); + let claude_session_id = options + .as_ref() + .and_then(|value| value.claude_session_id.clone()) + .unwrap_or_else(crate::claude_session::new_session_id); let term = terminal::create_terminal( working_directory, terminal::TerminalOptions { hover_focus, saved_font_size: (internals.callbacks.current_config)().borrow().font_size, + startup_command, + tab_id: Some(tab_id.clone()), + claude_session_id: Some(claude_session_id.clone()), }, term_callbacks, ); let widget: gtk::Widget = term.overlay.clone().upcast(); internals.content_stack.add_named(&widget, Some(&tab_id)); + let live_session: Rc>> = Rc::new(RefCell::new(None)); + spawn_claude_session_poller(tab_id.clone(), &live_session); + { let mut ts = internals.tab_state.borrow_mut(); ts.tabs.push(TabEntry { @@ -1088,6 +1158,8 @@ fn add_terminal_tab_inner( state: TerminalTabState { cwd: term_cwd.clone(), handle: term.handle.clone(), + claude_session_id, + live_claude_session: live_session.clone(), }, }, }); @@ -1384,9 +1456,23 @@ pub fn snapshot_pane_state(pane_widget: >k::Widget) -> Option { .iter() .map(|entry| { let content = match &entry.kind { - TabKind::Terminal { state } => TabContentState::Terminal { - cwd: state.cwd.borrow().clone(), - }, + TabKind::Terminal { state } => { + let cwd = state.cwd.borrow().clone(); + // Prefer the live UUID the poller observed inside this + // specific tab's claude process (this is what picks up + // `/resume` switches made from Claude's own UI). Fall back + // to the stable pre-generated UUID otherwise — that's the + // one the wrapper pins fresh `claude` invocations to. + let claude_session = state + .live_claude_session + .borrow() + .clone() + .unwrap_or_else(|| state.claude_session_id.clone()); + TabContentState::Terminal { + cwd, + claude_session: Some(claude_session), + } + } TabKind::Browser { state } => TabContentState::Browser { uri: state.uri.borrow().clone(), }, diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index ef07bcd9..736fc753 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -816,6 +816,17 @@ pub struct TerminalCallbacks { pub struct TerminalOptions { pub hover_focus: Rc bool>, pub saved_font_size: Option, + // When set, Ghostty spawns this shell command (via its `command` config) + // instead of the user's login shell. Used to auto-resume Claude sessions. + pub startup_command: Option, + // Opaque tab identifier exported into the shell's environment as + // `LIMUX_TAB_ID`. Currently informational; consumed by the wrapper + // script for debug logging if needed. + pub tab_id: Option, + // Pre-generated Claude session UUID. Exported as + // `LIMUX_CLAUDE_SESSION_ID` so the bundled wrapper can pin any + // `claude` invocation in this tab to this session. + pub claude_session_id: Option, } /// Default font-size from ghostty config (cached on first access). @@ -847,6 +858,9 @@ pub fn create_terminal( let wd = working_directory.map(|s| s.to_string()); let saved_font_size = options.saved_font_size; + let startup_command = options.startup_command; + let tab_id = options.tab_id; + let claude_session_id = options.claude_session_id; let hover_focus = options.hover_focus; let callbacks = Rc::new(RefCell::new(callbacks)); let surface_cell: Rc>> = Rc::new(RefCell::new(None)); @@ -985,6 +999,65 @@ pub fn create_terminal( config.working_directory = cwd.as_ptr(); } + // Ghostty treats `command` as a full shell line executed by the + // user's shell, so we pass the already-built string verbatim. + let c_command = startup_command + .as_ref() + .and_then(|cmd| CString::new(cmd.as_str()).ok()); + if let Some(ref cmd) = c_command { + config.command = cmd.as_ptr(); + } + + // Build the per-tab environment overrides. Ghostty copies the + // keys/values into its own arena during `ghostty_surface_new`, + // so these CStrings and the `env_vars` vector only need to + // outlive that single FFI call. + let mut env_cstrings: Vec = Vec::new(); + let mut env_vars: Vec = Vec::new(); + let mut push_env = |key: &str, value: String| { + let Ok(c_key) = CString::new(key) else { + return; + }; + let Ok(c_value) = CString::new(value) else { + return; + }; + env_cstrings.push(c_key); + env_cstrings.push(c_value); + let key_ptr = env_cstrings[env_cstrings.len() - 2].as_ptr(); + let value_ptr = env_cstrings[env_cstrings.len() - 1].as_ptr(); + env_vars.push(ghostty_env_var_s { + key: key_ptr, + value: value_ptr, + }); + }; + + if let Some(ref value) = tab_id { + push_env("LIMUX_TAB_ID", value.clone()); + } + if let Some(ref value) = claude_session_id { + push_env("LIMUX_CLAUDE_SESSION_ID", value.clone()); + } + // Prepend the wrapper directory to PATH so the shim claude + // binary is picked up before the real one. The wrapper strips + // itself back out before resolving the real binary. + if let Some(wrapper_dir) = crate::claude_session::wrapper_path_component() { + let existing_path = std::env::var_os("PATH").unwrap_or_default(); + let mut prepended = std::ffi::OsString::new(); + prepended.push(wrapper_dir.as_os_str()); + if !existing_path.is_empty() { + prepended.push(":"); + prepended.push(&existing_path); + } + if let Some(prepended_str) = prepended.to_str() { + push_env("PATH", prepended_str.to_string()); + } + } + + if !env_vars.is_empty() { + config.env_vars = env_vars.as_mut_ptr(); + config.env_var_count = env_vars.len(); + } + let surface = unsafe { ghostty_surface_new(app, &config) }; if surface.is_null() { unsafe { From 8dac836fbca2c027a5296759ac5d6a247c8d46b4 Mon Sep 17 00:00:00 2001 From: "MVB.Mir" Date: Fri, 17 Apr 2026 18:35:37 +0300 Subject: [PATCH 2/2] Fix clippy collapsible_match warnings (Rust 1.95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust 1.95 stable (released between main's last green CI run and this PR's) promotes collapsible_match to a warning, which the workspace's check.sh treats as an error via -D warnings. The flagged patterns in limux-core are `match combo_norm { pattern => { if palette_visible { … true } else { false } } … }` — they collapse cleanly into match guards: `pattern if palette_visible => { … true }`. Behavior is unchanged; any non-match combo hits the catch-all `_ => false` arm. Unrelated to the redesign; included here to keep CI green. --- rust/limux-core/src/lib.rs | 40 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/rust/limux-core/src/lib.rs b/rust/limux-core/src/lib.rs index af50fd19..4ccbab09 100644 --- a/rust/limux-core/src/lib.rs +++ b/rust/limux-core/src/lib.rs @@ -2689,37 +2689,21 @@ impl ControlState { } true } - "down" | "ctrl+n" | "ctrl+j" => { - if palette_visible { - self.command_palette_move_selection(window_id, 1); - true - } else { - false - } + "down" | "ctrl+n" | "ctrl+j" if palette_visible => { + self.command_palette_move_selection(window_id, 1); + true } - "up" | "ctrl+p" | "ctrl+k" => { - if palette_visible { - self.command_palette_move_selection(window_id, -1); - true - } else { - false - } + "up" | "ctrl+p" | "ctrl+k" if palette_visible => { + self.command_palette_move_selection(window_id, -1); + true } - "cmd+a" => { - if palette_visible { - self.command_palette_select_all(window_id); - true - } else { - false - } + "cmd+a" if palette_visible => { + self.command_palette_select_all(window_id); + true } - "enter" => { - if palette_visible { - self.command_palette_enter(window_id); - true - } else { - false - } + "enter" if palette_visible => { + self.command_palette_enter(window_id); + true } _ => false, }