From 494722f69bf79667b5796ff91496f992f58d23be Mon Sep 17 00:00:00 2001 From: Pavlo Grubyi Date: Sun, 7 Jun 2026 19:14:23 +0100 Subject: [PATCH 1/4] feat: add cmux-style UI refinements --- AGENTS.md | 26 + README.md | 49 +- docs/maintainability.md | 11 + hooks/README.md | 18 +- rust/limux-cli/src/agent_hooks.rs | 18 + rust/limux-cli/src/main.rs | 379 ++++- rust/limux-ghostty-sys/build.rs | 15 +- rust/limux-host-linux/icons/app/128.png | Bin 6558 -> 6583 bytes rust/limux-host-linux/icons/app/16.png | Bin 650 -> 527 bytes rust/limux-host-linux/icons/app/256.png | Bin 15798 -> 15850 bytes rust/limux-host-linux/icons/app/32.png | Bin 1463 -> 1218 bytes rust/limux-host-linux/icons/app/512.png | Bin 35016 -> 39969 bytes rust/limux-host-linux/src/app_config.rs | 104 +- rust/limux-host-linux/src/control_bridge.rs | 37 + rust/limux-host-linux/src/layout_state.rs | 15 + rust/limux-host-linux/src/pane.rs | 252 ++- rust/limux-host-linux/src/settings_editor.rs | 469 +++++- rust/limux-host-linux/src/shortcut_config.rs | 20 +- rust/limux-host-linux/src/terminal.rs | 48 +- rust/limux-host-linux/src/window.rs | 1583 ++++++++++++++++-- scripts/xvfb-smoke-test.sh | 91 +- 21 files changed, 2840 insertions(+), 295 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ba4ffd4..10656b95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,32 @@ For live agent/control-socket behavior, prefer the maintained smoke harness: LIMUX_SMOKE_PROFILE=debug ./scripts/xvfb-smoke-test.sh ``` +## Local Runtime Freshness + +For user-visible fixes, make sure the local `limux` entrypoint the maintainer +actually runs is updated before handing off. The fast local install path is: + +```bash +./scripts/install-local-build.sh +``` + +That script builds the CLI and GTK host, installs them under +`$LIMUX_LOCAL_PREFIX` or `~/.local`, installs a matching `libghostty.so`, +updates the desktop entry, and verifies that `command -v limux` resolves to the +freshly installed CLI and that the host resolves the matching local library. +Restart any already-running Limux GUI before validating the new behavior; a +running process keeps using the old mapped executable. + +If a change touches release packaging, Ghostty resources, system linker config, +or distro artifacts, use the full package path instead: + +```bash +./scripts/package.sh +``` + +When a runtime fix is not installed locally, say so explicitly in the handoff +instead of implying the user's active Limux app includes it. + ## Runtime Control Path There are two control-server paths: diff --git a/README.md b/README.md index 11660afb..e017e292 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,30 @@ sudo ./install.sh --uninstall sudo apt install libgtk-4-1 libadwaita-1-0 libwebkitgtk-6.0-4 ``` +### Troubleshooting Launches + +Installed packages expose `limux` as the CLI entrypoint. Running `limux` with +no arguments launches the private GTK host from `libexec/limux/limux-host`; +desktop files should point at the CLI, not directly at `limux-host`. + +If launch fails with an error like: + +```text +libghostty.so: undefined symbol: gladLoaderLoadGLContext +``` + +the CLI is usually finding a stale `limux-host` binary that does not match the +installed `libghostty.so`. Remove old manual-install host binaries from +`~/.local/bin/limux-host` or reinstall Limux so the CLI, host, and +`libghostty.so` come from the same package. Source builds also require the full +Ghostty submodule, including `ghostty/vendor/glad/src/gl.c`; a `ghostty/zig-out` +stub or copied `libghostty.so` is not enough to build a working host. + ## Build from source ### Prerequisites -- Rust toolchain (stable) +- Rust toolchain 1.92 or newer - Zig - GTK4, libadwaita, WebKitGTK dev packages - Initialized Ghostty submodule @@ -88,7 +107,7 @@ git submodule update --init --recursive cargo build --release # Run (point to libghostty.so location) -LD_LIBRARY_PATH=../ghostty/zig-out/lib:$LD_LIBRARY_PATH ./target/release/limux +LD_LIBRARY_PATH=ghostty/zig-out/lib:$LD_LIBRARY_PATH ./target/release/limux ``` ### Package a release tarball @@ -112,8 +131,8 @@ Repository maintainability rules live in [`docs/maintainability.md`](docs/mainta ## Agent integrations -Limux ships first-class hooks for coding agents (Codex, Claude Code, and -Gemini CLI). Every terminal limux spawns auto-exports +Limux ships first-class hooks for coding agents (Codex, Claude Code, Gemini +CLI, and Pi). Every terminal limux spawns auto-exports `LIMUX_WORKSPACE_ID` / `LIMUX_SURFACE_ID` / `LIMUX_PANE_ID` / `LIMUX_TAB_ID` / `LIMUX_SOCKET`, so the CLI auto-targets the right place with no flags needed from inside the agent's own terminal. @@ -128,6 +147,7 @@ limux hooks setup # Drop-in hook handlers translate hook JSON on stdin into notify/session state echo '{"event":"stop"}' | limux claude-hook --event stop echo '{"event":"finished"}' | limux gemini-hook --event finished +echo '{"event":"stop"}' | limux pi-hook --event stop # Spin up a multi-agent collaboration team — one workspace per agent, # launches each agent's CLI, and writes AGENTS.md describing the @@ -155,15 +175,30 @@ limux send --workspace "$LIMUX_WORKSPACE_ID" --surface "" \ See the auto-generated `AGENTS.md` (written into the shared cwd) for the full protocol spec, peer table, and editable Policies section. -Checked-in hook templates live in [`hooks/`](hooks/). They mirror -`limux hooks setup` for Codex, Claude Code, and Gemini CLI; OpenCode is -omitted until its hook integration is ready. +Checked-in JSON hook templates live in [`hooks/`](hooks/). They mirror +`limux hooks setup` for Codex, Claude Code, and Gemini CLI. Pi is installed as +a generated extension under `~/.pi/agent/extensions/`; OpenCode is omitted +from default setup until its hook integration is ready. + +After installing Pi hooks, restart any already-running Pi session so it loads +the generated extension. Set `LIMUX_PI_HOOKS_DISABLED=1` to disable the Pi +extension without uninstalling it. Coding agents working on **limux itself** should read [`AGENTS.md`](AGENTS.md) and [`CLAUDE.md`](CLAUDE.md) in the repo root — those cover the build loop, crate map, and the `feat/cmux-parity` roadmap tracked in [`docs/cmux-parity-plan.md`](docs/cmux-parity-plan.md). +## Settings + +Open Settings from the gear icon in any pane header. Limux writes preferences +to `~/.config/limux/settings.json` by default and preserves unrelated keys +when editing settings. + +The Fonts & Icons page controls terminal text size, Limux chrome text sizes, +and pane header icon sizes. Terminal font shortcuts below update the same +saved terminal text setting. + ## Keyboard shortcuts Most default shortcuts use `Ctrl`. Fullscreen defaults to `F11`. Custom remaps may also use `Cmd`, which Limux maps to either the Linux `Meta` or `Super` modifier. `Opt` maps to `Alt`. diff --git a/docs/maintainability.md b/docs/maintainability.md index b6a91a88..111436b6 100644 --- a/docs/maintainability.md +++ b/docs/maintainability.md @@ -31,3 +31,14 @@ That script is the source of truth for the repository quality gate and currently - Keep pure logic separate from GTK widget wiring where possible. - Move test modules out of large production files when they obscure the main codepath. - Treat clippy findings as maintainability work, not optional cleanup. + +## GTK UI Styling + +- Sidebar workspace rows intentionally override the theme-provided + `.navigation-sidebar > row` horizontal padding and margins so selected and + unread row backgrounds span the full sidebar width. +- Keep the sidebar unread indicator as an inset `box-shadow`, not a border, + so marking a workspace unread does not change row width or text alignment. +- When changing sidebar row CSS in `rust/limux-host-linux/src/window.rs`, keep + the row-inset and unread-width regression tests updated with the intended + visual contract. diff --git a/hooks/README.md b/hooks/README.md index b023c75e..bf638e6e 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -1,8 +1,8 @@ # Limux Agent Hooks These templates wire supported coding-agent hook systems into Limux session -restore tracking. They are intentionally limited to Codex, Claude Code, and -Gemini CLI until the OpenCode hook path is ready. +restore tracking. Default setup covers Codex, Claude Code, Gemini CLI, and Pi. +OpenCode remains omitted from default setup until its hook path is ready. The preferred install path is the CLI installer: @@ -17,14 +17,25 @@ That writes the equivalent configuration into each agent's user config: | Codex | `$CODEX_HOME/hooks.json` or `~/.codex/hooks.json` | | Claude Code | `$CLAUDE_CONFIG_DIR/settings.json` or `~/.claude/settings.json` | | Gemini CLI | `~/.gemini/settings.json` | +| Pi | `~/.pi/agent/extensions/limux-hooks.ts` | Use the files in this directory as canonical examples when reviewing or -manually repairing an agent config: +manually repairing JSON-based agent configs: - `codex-hooks.json` - `claude-settings.json` - `gemini-settings.json` +Pi uses a generated TypeScript extension instead of a checked-in JSON template. +Regenerate it with: + +```bash +limux hooks pi install +``` + +Restart any already-running Pi session after installing so Pi loads the +extension. New Pi sessions will load it automatically. + Each command calls `limux --json hooks ` and is guarded by a per-agent disable variable: @@ -32,4 +43,5 @@ per-agent disable variable: LIMUX_CODEX_HOOKS_DISABLED=1 LIMUX_CLAUDE_HOOKS_DISABLED=1 LIMUX_GEMINI_HOOKS_DISABLED=1 +LIMUX_PI_HOOKS_DISABLED=1 ``` diff --git a/rust/limux-cli/src/agent_hooks.rs b/rust/limux-cli/src/agent_hooks.rs index 552c4072..f2555aed 100644 --- a/rust/limux-cli/src/agent_hooks.rs +++ b/rust/limux-cli/src/agent_hooks.rs @@ -14,6 +14,7 @@ pub(crate) enum AgentKind { Codex, OpenCode, Gemini, + Pi, } impl AgentKind { @@ -23,6 +24,7 @@ impl AgentKind { "codex" => Some(Self::Codex), "opencode" | "open-code" => Some(Self::OpenCode), "gemini" => Some(Self::Gemini), + "pi" | "pi-coding-agent" | "pi-coding" => Some(Self::Pi), _ => None, } } @@ -33,6 +35,7 @@ impl AgentKind { Self::Codex => "codex", Self::OpenCode => "opencode", Self::Gemini => "gemini", + Self::Pi => "pi", } } @@ -42,6 +45,7 @@ impl AgentKind { Self::Codex => "Codex", Self::OpenCode => "OpenCode", Self::Gemini => "Gemini", + Self::Pi => "Pi", } } @@ -51,6 +55,7 @@ impl AgentKind { Self::Codex => "codex", Self::OpenCode => "opencode", Self::Gemini => "gemini", + Self::Pi => "pi", } } } @@ -278,6 +283,11 @@ pub(crate) fn build_resume_command( parts.push(session_id); parts.extend(preserved_tail); } + AgentKind::Pi => { + parts.push("--session".to_string()); + parts.push(session_id); + parts.extend(preserved_tail); + } } let command = parts @@ -368,6 +378,12 @@ fn is_resume_selector(kind: AgentKind, arg: &str) -> bool { AgentKind::Claude | AgentKind::Gemini => { arg == "--resume" || arg.starts_with("--resume=") || arg == "--continue" } + AgentKind::Pi => { + arg == "--session" + || arg.starts_with("--session=") + || arg == "--continue" + || arg == "-c" + } } } @@ -448,6 +464,8 @@ fn selected_environment() -> BTreeMap { "CLAUDE_CONFIG_DIR", "OPENCODE_CONFIG_DIR", "GEMINI_CONFIG_DIR", + "PI_CODING_AGENT_DIR", + "PI_CODING_AGENT_SESSION_DIR", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", diff --git a/rust/limux-cli/src/main.rs b/rust/limux-cli/src/main.rs index 1ea336b5..c33d25e7 100644 --- a/rust/limux-cli/src/main.rs +++ b/rust/limux-cli/src/main.rs @@ -199,7 +199,7 @@ fn parse_global_args() -> Result { fn print_help() { println!( - "limux CLI\n\nUsage: limux [--socket ] [--json] [--id-format refs|both|uuids] [args...]\n limux\n\nRunning `limux` with no arguments launches the GTK app.\n\nCommon commands:\n identify [--workspace ] [--surface ]\n list-panels [--workspace ]\n list-panes [--workspace ]\n list-workspaces\n surface-health [--workspace ]\n send [--workspace ] [--surface ] \n send-key [--workspace ] [--surface ] \n new-workspace [--cwd ] [--command ]\n close-workspace --workspace \n sidebar-state --workspace \n new-surface [--workspace ]\n new-pane [--workspace ] [--pane ] [--surface ] [--direction ] [--type ] [--command ] [--url ]\n Live GTK self-spawn currently supports terminal panes only; browser panes remain deferred.\n rename-workspace [--workspace ] \n rename-window [--workspace <id|ref>] <title>\n rename-tab [--workspace <id|ref>] [--tab <id|ref>] <title>\n read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]\n capture-pane (alias of read-screen)\n tab-action --action <name> [--workspace <id|ref>] [--tab <id|ref>] [--title <text>] [--url <url>]\n browser [--surface <id|ref>|<surface>] <subcommand> ...\n\nAgent integrations:\n notify [--workspace <id|ref>] [--subtitle <text>] [--body <text>] <title>\n hooks setup [agent] | hooks uninstall [agent] | hooks <agent> <event>\n claude-hook | opencode-hook | gemini-hook --event <name> [--subtitle <text>] [--body <text>] [--title <text>]\n agent-team [--agents codex,claude[,opencode,gemini]] [--cwd <path>] [--no-launch] [--dry-run]\n Splits the active workspace into one pane per agent (caller's pane stays\n as the orchestrator on the left, peers stack down the right), launches\n each CLI in its pane, and writes AGENTS.md describing the <agent-msg>\n XML protocol so peers can talk via\n `limux send --surface <peer-surface-id> <envelope>`.\n" + "limux CLI\n\nUsage: limux [--socket <path>] [--json] [--id-format refs|both|uuids] <command> [args...]\n limux\n\nRunning `limux` with no arguments launches the GTK app.\n\nCommon commands:\n identify [--workspace <id|ref>] [--surface <id|ref>]\n list-panels [--workspace <id|ref>]\n list-panes [--workspace <id|ref>]\n list-workspaces\n surface-health [--workspace <id|ref>]\n send [--workspace <id|ref>] [--surface <id|ref>] <text>\n send-key [--workspace <id|ref>] [--surface <id|ref>] <key>\n new-workspace [--cwd <path>] [--command <text>]\n close-workspace --workspace <id|ref>\n sidebar-state --workspace <id|ref>\n new-surface [--workspace <id|ref>]\n new-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--direction <left|right|up|down>] [--type <terminal|browser>] [--command <text>] [--url <url>]\n Live GTK self-spawn currently supports terminal panes only; browser panes remain deferred.\n rename-workspace [--workspace <id|ref>] <title>\n rename-window [--workspace <id|ref>] <title>\n rename-tab [--workspace <id|ref>] [--tab <id|ref>] <title>\n read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]\n capture-pane (alias of read-screen)\n tab-action --action <name> [--workspace <id|ref>] [--tab <id|ref>] [--title <text>] [--url <url>]\n browser [--surface <id|ref>|<surface>] <subcommand> ...\n\nAgent integrations:\n notify [--workspace <id|ref>] [--kind attention|finished] [--subtitle <text>] [--body <text>] <title>\n hooks setup [codex|claude|gemini|pi] | hooks uninstall [agent] | hooks <agent> <event>\n claude-hook | opencode-hook | gemini-hook | pi-hook --event <name> [--subtitle <text>] [--body <text>] [--title <text>]\n agent-team [--agents codex,claude[,opencode,gemini]] [--cwd <path>] [--no-launch] [--dry-run]\n Splits the active workspace into one pane per agent (caller's pane stays\n as the orchestrator on the left, peers stack down the right), launches\n each CLI in its pane, and writes AGENTS.md describing the <agent-msg>\n XML protocol so peers can talk via\n `limux send --surface <peer-surface-id> <envelope>`.\n" ); } @@ -218,23 +218,47 @@ fn host_binary_candidates(exe: &Path) -> Vec<PathBuf> { if let Some(bin_dir) = exe.parent() { if let Some(prefix) = bin_dir.parent() { - candidates.push(prefix.join("libexec/limux/limux-host")); + push_host_binary_candidate( + &mut candidates, + prefix.join("libexec/limux/limux-host"), + exe, + ); } - let sibling_host = bin_dir.join("limux-host"); - if sibling_host != exe { - candidates.push(sibling_host); + // Development builds place the CLI and GTK host side by side under + // target/{debug,release}. Keep that ahead of any installed fallback. + if exe.file_name().and_then(|name| name.to_str()) == Some("limux-cli") { + push_host_binary_candidate(&mut candidates, bin_dir.join("limux"), exe); } + } + + for installed in [ + "/usr/libexec/limux/limux-host", + "/usr/local/libexec/limux/limux-host", + ] { + push_host_binary_candidate(&mut candidates, PathBuf::from(installed), exe); + } + + if let Some(bin_dir) = exe.parent() { + // Legacy/manual installs sometimes put limux-host next to the CLI. + // Prefer libexec layouts first so stale sibling hosts do not shadow + // a valid package install. + let sibling_host = bin_dir.join("limux-host"); + push_host_binary_candidate(&mut candidates, sibling_host, exe); let sibling_dev_host = bin_dir.join("limux"); - if sibling_dev_host != exe { - candidates.push(sibling_dev_host); - } + push_host_binary_candidate(&mut candidates, sibling_dev_host, exe); } candidates } +fn push_host_binary_candidate(candidates: &mut Vec<PathBuf>, candidate: PathBuf, exe: &Path) { + if candidate != exe && !candidates.contains(&candidate) { + candidates.push(candidate); + } +} + fn resolve_host_binary() -> Result<PathBuf> { if let Ok(raw) = env::var("LIMUX_HOST_BIN") { let path = PathBuf::from(raw); @@ -249,7 +273,7 @@ fn resolve_host_binary() -> Result<PathBuf> { .find(|path| path.is_file()) .ok_or_else(|| { anyhow!( - "could not find limux host binary; expected limux-host next to the installed CLI" + "could not find limux host binary; expected libexec/limux/limux-host for the installed CLI" ) }) } @@ -802,7 +826,7 @@ async fn run_send_key(client: &mut Client, args: &[String]) -> Result<Value> { /// `limux notify` — post a notification into the sidebar + toast overlay. /// /// Usage: -/// limux notify [--workspace <id|ref>] [--subtitle <text>] [--body <text>] <title> +/// limux notify [--workspace <id|ref>] [--kind attention|finished] [--subtitle <text>] [--body <text>] <title> /// limux notify --title "..." --subtitle "..." --body "..." /// /// Mirrors the `cmux notify` shape (title / subtitle / body). Title is @@ -823,6 +847,7 @@ async fn run_notify(client: &mut Client, args: &[String]) -> Result<Value> { let body = parse_opt(args, "--body") .or_else(|| parse_opt(args, "--message")) .unwrap_or_default(); + let kind = parse_opt(args, "--kind").or_else(|| parse_opt(args, "--status")); let mut params = Map::new(); params.insert("title".to_string(), Value::String(title)); @@ -832,6 +857,9 @@ async fn run_notify(client: &mut Client, args: &[String]) -> Result<Value> { if !body.is_empty() { params.insert("body".to_string(), Value::String(body)); } + if let Some(kind) = kind.filter(|value| !value.trim().is_empty()) { + params.insert("kind".to_string(), Value::String(kind)); + } call_in_workspace_scope( client, @@ -843,7 +871,7 @@ async fn run_notify(client: &mut Client, args: &[String]) -> Result<Value> { } // --------------------------------------------------------------------------- -// Agent hooks (claude-hook / opencode-hook / gemini-hook) +// Agent hooks (claude-hook / opencode-hook / gemini-hook / pi-hook) // --------------------------------------------------------------------------- // // These subcommands read a JSON hook event from stdin and translate it into @@ -910,53 +938,7 @@ async fn run_agent_hook( // Build a human-friendly title + body depending on event + agent. let agent_label = agent.label(); persist_agent_hook_session(agent, args, &payload, &event)?; - let (title, body) = match event.as_str() { - "Notification" => ( - format!("{agent_label} needs you"), - hook_str(&payload, &["message", "notification"]) - .unwrap_or("waiting for input") - .to_owned(), - ), - "Stop" | "SubagentStop" => ( - format!("{agent_label} finished"), - hook_str(&payload, &["message", "reason"]) - .unwrap_or("task complete") - .to_owned(), - ), - "SessionStart" => ( - format!("{agent_label} session started"), - hook_str(&payload, &["cwd", "source"]) - .unwrap_or("") - .to_owned(), - ), - "SessionEnd" => ( - format!("{agent_label} session ended"), - hook_str(&payload, &["reason"]).unwrap_or("").to_owned(), - ), - "PreToolUse" | "PostToolUse" => ( - format!( - "{agent_label}: {}", - hook_str(&payload, &["tool_name"]).unwrap_or("tool") - ), - hook_str(&payload, &["tool_input", "summary"]) - .unwrap_or("") - .to_owned(), - ), - "UserPromptSubmit" => ( - format!("{agent_label}: new prompt"), - hook_str(&payload, &["prompt"]) - .unwrap_or("") - .chars() - .take(120) - .collect(), - ), - other => ( - format!("{agent_label}: {other}"), - hook_str(&payload, &["message", "summary"]) - .unwrap_or("") - .to_owned(), - ), - }; + let (title, body) = agent_hook_notification_content(agent_label, &event, &payload); let subtitle = hook_str(&payload, &["session_id"]) .map(|s| { @@ -977,6 +959,15 @@ async fn run_agent_hook( if !body.is_empty() { params.insert("body".to_string(), Value::String(body)); } + if let Some(surface_id) = parse_opt(args, "--surface") + .or_else(|| env::var("LIMUX_SURFACE_ID").ok()) + .filter(|value| !value.trim().is_empty()) + { + params.insert("surface_id".to_string(), Value::String(surface_id)); + } + if let Some(kind) = agent_hook_notification_kind(&event) { + params.insert("kind".to_string(), Value::String(kind.to_string())); + } let _ = call_in_workspace_scope( client, @@ -989,6 +980,76 @@ async fn run_agent_hook( Ok(agent_hook_output(&event, &payload)) } +fn agent_hook_notification_content( + agent_label: &str, + event: &str, + payload: &Value, +) -> (String, String) { + match canonical_agent_hook_display_event(event) { + AgentHookDisplayEvent::Notification => ( + format!("{agent_label} needs you"), + hook_str(payload, &["message", "notification"]) + .unwrap_or("waiting for input") + .to_owned(), + ), + AgentHookDisplayEvent::Stop => ( + "Process needs attention".to_string(), + hook_str(payload, &["message", "reason"]) + .map(str::to_owned) + .unwrap_or_else(|| format!("{agent_label} finished")), + ), + AgentHookDisplayEvent::SessionStart => ( + format!("{agent_label} session started"), + hook_str(payload, &["cwd", "source"]) + .unwrap_or("") + .to_owned(), + ), + AgentHookDisplayEvent::SessionEnd => ( + format!("{agent_label} session ended"), + hook_str(payload, &["reason"]).unwrap_or("").to_owned(), + ), + AgentHookDisplayEvent::ToolUse => ( + format!( + "{agent_label}: {}", + hook_str(payload, &["tool_name"]).unwrap_or("tool") + ), + hook_str(payload, &["tool_input", "summary"]) + .unwrap_or("") + .to_owned(), + ), + AgentHookDisplayEvent::UserPromptSubmit => ( + format!("{agent_label}: new prompt"), + hook_str(payload, &["prompt"]) + .unwrap_or("") + .chars() + .take(120) + .collect(), + ), + AgentHookDisplayEvent::Other => { + let event_label = event.trim(); + let event_label = if event_label.is_empty() { + "event" + } else { + event_label + }; + ( + format!("{agent_label}: {event_label}"), + hook_str(payload, &["message", "summary"]) + .unwrap_or("") + .to_owned(), + ) + } + } +} + +fn agent_hook_notification_kind(event: &str) -> Option<&'static str> { + match canonical_agent_hook_display_event(event) { + AgentHookDisplayEvent::Notification => Some("attention"), + AgentHookDisplayEvent::Stop => Some("finished"), + _ => None, + } +} + fn agent_hook_output(event: &str, payload: &Value) -> Value { let canonical_event = canonical_hook_event_name(event); let mut output = Map::new(); @@ -1025,6 +1086,34 @@ fn canonical_hook_event_name(event: &str) -> Option<&'static str> { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AgentHookDisplayEvent { + Notification, + Stop, + SessionStart, + SessionEnd, + ToolUse, + UserPromptSubmit, + Other, +} + +fn canonical_agent_hook_display_event(event: &str) -> AgentHookDisplayEvent { + match event.trim() { + "Notification" | "notification" => AgentHookDisplayEvent::Notification, + "Stop" | "stop" | "SubagentStop" | "subagent-stop" | "subagent_stop" => { + AgentHookDisplayEvent::Stop + } + "SessionStart" | "session-start" | "session_start" => AgentHookDisplayEvent::SessionStart, + "SessionEnd" | "session-end" | "session_end" => AgentHookDisplayEvent::SessionEnd, + "PreToolUse" | "pre-tool-use" | "pre_tool_use" | "PostToolUse" | "post-tool-use" + | "post_tool_use" => AgentHookDisplayEvent::ToolUse, + "UserPromptSubmit" | "prompt-submit" | "user-prompt-submit" | "user_prompt_submit" => { + AgentHookDisplayEvent::UserPromptSubmit + } + _ => AgentHookDisplayEvent::Other, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AgentHookPersistenceAction { Upsert, @@ -1338,7 +1427,7 @@ async fn run_hooks_command( ) -> Result<CommandOutput> { let Some(first) = args.first().map(String::as_str) else { bail!( - "Usage: limux hooks setup [agent]|uninstall [agent]|<agent> install|uninstall|<event>" + "Usage: limux hooks setup [codex|claude|gemini|pi]|uninstall [agent]|<agent> install|uninstall|<event>" ); }; @@ -1450,6 +1539,7 @@ fn default_hook_targets() -> Vec<agent_hooks::AgentKind> { agent_hooks::AgentKind::Codex, agent_hooks::AgentKind::Claude, agent_hooks::AgentKind::Gemini, + agent_hooks::AgentKind::Pi, ] } @@ -1486,6 +1576,7 @@ fn install_hook_target(agent: agent_hooks::AgentKind) -> Result<()> { ("SessionEnd", "session-end"), ], ), + agent_hooks::AgentKind::Pi => install_pi_extension(), } } @@ -1502,6 +1593,7 @@ fn uninstall_hook_target(agent: agent_hooks::AgentKind) -> Result<()> { opencode_config_unregister_plugin() } agent_hooks::AgentKind::Gemini => uninstall_json_hooks(&gemini_settings_path(), agent), + agent_hooks::AgentKind::Pi => uninstall_pi_extension(), } } @@ -1558,7 +1650,9 @@ fn install_json_hooks( fn hook_timeout(agent: agent_hooks::AgentKind) -> u64 { match agent { agent_hooks::AgentKind::Claude => 5, - agent_hooks::AgentKind::Codex | agent_hooks::AgentKind::Gemini => 5000, + agent_hooks::AgentKind::Codex + | agent_hooks::AgentKind::Gemini + | agent_hooks::AgentKind::Pi => 5000, agent_hooks::AgentKind::OpenCode => 0, } } @@ -1672,9 +1766,107 @@ fn hook_marker(agent: agent_hooks::AgentKind) -> &'static str { agent_hooks::AgentKind::Codex => "hooks codex", agent_hooks::AgentKind::OpenCode => "hooks opencode", agent_hooks::AgentKind::Gemini => "hooks gemini", + agent_hooks::AgentKind::Pi => "hooks pi", } } +fn pi_extension_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".pi/agent/extensions/limux-hooks.ts") +} + +fn install_pi_extension() -> Result<()> { + let path = pi_extension_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write(&path, pi_extension_source()).context("failed to write Pi extension") +} + +fn uninstall_pi_extension() -> Result<()> { + let path = pi_extension_path(); + if path.exists() { + fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?; + } + Ok(()) +} + +fn pi_extension_source() -> &'static str { + r#"// Installed by `limux hooks pi install`. Do not edit manually. +import { spawnSync } from "node:child_process"; + +type ExtensionAPI = { + on(event: string, handler: (...args: any[]) => unknown): void; +}; + +type ExtensionContext = { + cwd?: unknown; + sessionManager?: { + getSessionFile?: () => unknown; + }; +}; + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function transcriptPath(ctx: ExtensionContext): string | undefined { + try { + return optionalString(ctx.sessionManager?.getSessionFile?.()); + } catch { + return undefined; + } +} + +function send( + eventName: string, + ctx: ExtensionContext | undefined, + extra: Record<string, unknown> = {}, +) { + if (process.env.LIMUX_PI_HOOKS_DISABLED === "1") return; + + try { + const safeCtx = ctx ?? {}; + const payload = { + transcript_path: transcriptPath(safeCtx), + cwd: optionalString(safeCtx.cwd) ?? process.cwd(), + pid: process.pid, + hook_event_name: eventName, + ...extra, + }; + spawnSync("limux", ["--json", "hooks", "pi", eventName], { + input: JSON.stringify(payload), + encoding: "utf8", + timeout: 5000, + stdio: ["pipe", "ignore", "ignore"], + }); + } catch { + // Hooks must never break pi. + } +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", (event: any, ctx: ExtensionContext | undefined) => { + send("session-start", ctx, { reason: event?.reason }); + }); + + pi.on("before_agent_start", (event: any, ctx: ExtensionContext | undefined) => { + send("prompt-submit", ctx, { prompt: event?.prompt }); + }); + + pi.on("agent_end", (_event: any, ctx: ExtensionContext | undefined) => { + send("stop", ctx); + }); + + pi.on("session_shutdown", (event: any, ctx: ExtensionContext | undefined) => { + send("session-end", ctx, { reason: event?.reason }); + }); +} +"# +} + fn read_json_object(path: &Path) -> Result<Map<String, Value>> { if !path.exists() { return Ok(Map::new()); @@ -3425,11 +3617,12 @@ async fn execute_command(client: &mut Client, opts: &GlobalOptions) -> Result<Co CommandOutput::Text("OK".to_string()) } } - "claude-hook" | "opencode-hook" | "gemini-hook" => { + "claude-hook" | "opencode-hook" | "gemini-hook" | "pi-hook" => { let agent = match command { "claude-hook" => agent_hooks::AgentKind::Claude, "opencode-hook" => agent_hooks::AgentKind::OpenCode, "gemini-hook" => agent_hooks::AgentKind::Gemini, + "pi-hook" => agent_hooks::AgentKind::Pi, _ => unreachable!(), }; let payload = run_agent_hook(client, agent, args).await?; @@ -3679,12 +3872,36 @@ mod cli_arg_tests { fn host_binary_candidates_cover_installed_and_dev_layouts() { let installed = Path::new("/usr/bin/limux"); let candidates = host_binary_candidates(installed); - assert!(candidates.contains(&PathBuf::from("/usr/libexec/limux/limux-host"))); + assert_eq!( + candidates.first(), + Some(&PathBuf::from("/usr/libexec/limux/limux-host")) + ); assert!(!candidates.contains(&PathBuf::from("/usr/bin/limux"))); let dev = Path::new("/repo/target/debug/limux-cli"); let candidates = host_binary_candidates(dev); - assert!(candidates.contains(&PathBuf::from("/repo/target/debug/limux"))); + assert_eq!( + candidates.get(1), + Some(&PathBuf::from("/repo/target/debug/limux")) + ); + } + + #[test] + fn host_binary_candidates_prefer_installed_libexec_over_legacy_sibling() { + let local = Path::new("/home/user/.local/bin/limux"); + let candidates = host_binary_candidates(local); + let system_libexec = PathBuf::from("/usr/libexec/limux/limux-host"); + let sibling_host = PathBuf::from("/home/user/.local/bin/limux-host"); + let system_idx = candidates + .iter() + .position(|path| path == &system_libexec) + .expect("system libexec candidate"); + let sibling_idx = candidates + .iter() + .position(|path| path == &sibling_host) + .expect("legacy sibling candidate"); + + assert!(system_idx < sibling_idx); } #[test] @@ -3756,6 +3973,7 @@ mod cli_arg_tests { agent_hooks::AgentKind::Codex, agent_hooks::AgentKind::Claude, agent_hooks::AgentKind::Gemini, + agent_hooks::AgentKind::Pi, ] ); assert!(!default_hook_targets().contains(&agent_hooks::AgentKind::OpenCode)); @@ -3782,6 +4000,16 @@ mod cli_arg_tests { assert!(source.contains("type === \"session.compacted\"")); } + #[test] + fn pi_extension_uses_best_effort_session_file() { + let source = pi_extension_source(); + + assert!(source.contains("getSessionFile")); + assert!(source.contains("hooks\", \"pi\"")); + assert!(!source.contains("getSessionId")); + assert!(!source.contains("@earendil-works/pi-coding-agent")); + } + #[test] fn stop_hook_output_matches_codex_schema_shape() { let output = agent_hook_output("stop", &json!({ "session_id": "session-a" })); @@ -3795,6 +4023,31 @@ mod cli_arg_tests { ); } + #[test] + fn lowercase_stop_hook_builds_attention_notification() { + assert_eq!( + canonical_agent_hook_display_event("stop"), + AgentHookDisplayEvent::Stop + ); + assert_eq!( + agent_hook_notification_content("Codex", "stop", &json!({})), + ( + "Process needs attention".to_string(), + "Codex finished".to_string() + ) + ); + } + + #[test] + fn hook_notification_kind_separates_finished_from_attention() { + assert_eq!( + agent_hook_notification_kind("Notification"), + Some("attention") + ); + assert_eq!(agent_hook_notification_kind("stop"), Some("finished")); + assert_eq!(agent_hook_notification_kind("session-start"), None); + } + #[test] fn session_start_hook_output_uses_camel_case_specific_output() { let output = agent_hook_output( diff --git a/rust/limux-ghostty-sys/build.rs b/rust/limux-ghostty-sys/build.rs index 0ff2a58f..6f30b35c 100644 --- a/rust/limux-ghostty-sys/build.rs +++ b/rust/limux-ghostty-sys/build.rs @@ -17,16 +17,21 @@ fn main() { // include when built as a shared library. let glad_src = ghostty_root.join("vendor/glad/src/gl.c"); let glad_include = ghostty_root.join("vendor/glad/include"); - if glad_src.exists() { - cc::Build::new() - .file(&glad_src) - .include(&glad_include) - .compile("glad"); + if !glad_src.is_file() || !glad_include.is_dir() { + panic!( + "Ghostty GLAD source not found at {}; initialize the ghostty submodule before building limux-host-linux", + glad_src.display() + ); } + cc::Build::new() + .file(&glad_src) + .include(&glad_include) + .compile("glad"); // Re-run if libghostty changes println!( "cargo:rerun-if-changed={}", ghostty_lib.join("libghostty.so").display() ); + println!("cargo:rerun-if-changed={}", glad_src.display()); } diff --git a/rust/limux-host-linux/icons/app/128.png b/rust/limux-host-linux/icons/app/128.png index 34192bba58a3fea62a99fee8acaa92a7ff1bac47..4bbcdaacdbf748e17570e6755e108e3e8332bfa9 100644 GIT binary patch literal 6583 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 literal 6558 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 Q_5c6?07*qoM6N<$f@joRxBvhE diff --git a/rust/limux-host-linux/icons/app/16.png b/rust/limux-host-linux/icons/app/16.png index 20f65a4d749ec09e69d601469e0e5ae6df9434de..035e44c5f1be6c809c4b6eb5d02f8576a9ea2057 100644 GIT binary patch delta 502 zcmV<S0SW$!1&;)fBYy$bNkl<Zc-pO!v1^n;5XFD9yXHKjrx=VVLMjVELC_+aLV}G* zK(MzLL@O=C*22n4{{cY@yVRawp$MiC6$A@~AOSrQ5AQVJe!KfD?vmWaB85H}hGF(S zX5K9PBW$#gbQCxZG&c1$%oXs`%-;T`B<ZH4qODO<O1CzXPk%{L&bg$N>XcF~<vOKQ z*TZ@wiX_Dt=Kvf)DP;jrk&<^Vv{zziFIT}k3&B|s7R&|#BtYK#1%NOVXgcRi!a#go z%`AS1d|Js|Jl0@p(vy-fTld9EDb3LdG~9(;#QgL&PV99&ezVH;hd+7#z9Iy04oWGE za@@EVg;EL!CVxFwj_=^|Y=eFjw;%Mm`>4;iA9>^;18foylBjc`H326N1*Uf!zbkn4 zuA<XTI5#e^<^k*33<tEA6Zc<4zJJb~oAX>cyPf?zJu!-AbAvM`kV>KHjKwdBJFg=% z4sM*EVE)JiK}M>zzJjC;fniYwLdM5lVs;9y9d0l)6>mrhGJ`9uy&YRY(h}fIs()qK zg?*kq2LrnrMpb2;v$Z1u<RNcq%#3cg`$UpT+4?v4dcCJ(69BYYt%ZKSf7N?`CdRrU s6$8x7@ZQVJ<eZCU_N>$C+~a@Y4<y=JxAD-Wy#N3J07*qoM6N<$g1|B8qW}N^ delta 626 zcmV-&0*(ET1d0WaBYy%>Nkl<Zc$}S6O=uHA6#m}K?53I@Yl69GOB5s&EJb2H=uMP@ z2fYf4co02lFXEwCRBR7DdJrleR6I$qQoJc5LT#;9lpX{%1T0!(W14K!&CWd8%_eRU zL3}U_%*^+__r328@H+(lJvwhGvU4uipJ$c@KzB2fjNCI3Yky|(lNoPHrG@ux425i_ zx3~W?kr*cuDqxfnFb2%re%oSVW@EhZQt92Ok0t-Gr)SSTDb=j;0f0xwpt<Hi#S<VR zurZ)^Eg&g`BxZA{SX_7#Y7vJ4!6Zb}tF5ZOQ)kt{xnadlO=)*s31umO!o?myhM9+= z&Da{$Nxo8zu76Aheb+8x@X8%*$!>>R`{5@Js%hzA0kDrjOD}P-v(Zq=6c+DJ!b)sI z=ExBCW(QGxIEGIzr$C|$LRn!iApf+E8B$6(l@-ifAI67?(ID%tqZ8PF{1)P=ER6OQ z(BHjhpTv%#>8_*v_61NcLo<-xaS)r5EduJe8%o#^M1L@iHRO+-MDEO0aNR=n=`>~^ z-9W8UhGlOCv)Nby5rPvE63x{r@+VGX;N}?EZQ%3$5zIZf0H<1oWyd36;!Xv|z>yMx z*{nSzckV{z**xY(E@9=%M_7q8{2Y<Yh6wejgn1$eqqV0JDR1%KBsI$4;WixD$u!t_ z;T#5i)@%N>GJCJv5=1s@Pix(LETv^D+g9~&UnLXCSRy3}EQL^=rIa1h+I{YM_EdY6 zgF!D83fA(ncOJk22z0}+Z2gr^$9?~*M3`}1b$e;)l@s}F{}loF7uNLnxK<DrQ~&?~ M07*qoM6N<$f_g|J4*&oF diff --git a/rust/limux-host-linux/icons/app/256.png b/rust/limux-host-linux/icons/app/256.png index 0bcb0745cf5a841528800facffa2d17924b88e72..24659adab832a661a8a6091acd7a4155ff60a072 100644 GIT binary patch literal 15850 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 literal 15798 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 C+v*em diff --git a/rust/limux-host-linux/icons/app/32.png b/rust/limux-host-linux/icons/app/32.png index 6f3c383f2902a749c8952693a2eb76921e4707bd..5950fccc1a8b54ec3385acb4d5ed53b8d77ec45b 100644 GIT binary patch delta 1199 zcmV;g1W^083&IJIBYy;mNkl<Zc-rlk-HTOK7{-6^+H0SEJo6=^;a6swWej6h3TA;x z5RrjIbrXRX)n(9CVE6q8b=gIcNZ%j^5q6atIs}rS!c>@PmUC=6j&o-3efC=G?c$s> z&Wv+r&REcmFKjlfwf9=jdfw;#Sa8SwXOm77s=5i-59|h30e@zp@8|vw1Yis}27D+Y zzjTdCRo_t63H|TvhN`~V?dET_|MK1!-h1b7-<<cp2qCnWe0xC`sOom$hY*4TjEERj zZGNRqURi=407L{;4L~r)SYW4!jI`&?%#XeI2U<V?OzVv*cm>l!;I%o{C8M*}3LzYr zIX?}cTCMh}>VK1}imFC4)g-`Qndi`jl;0;j0yu@BEJasQYwg|R<KqLj0O;%MTMevG zRYYW#o`XW#2%H=*c<<~KCobi<pm+rrZn~xmSD>ncl}csJd@hJOxmSUPhe{+&1vqp* z<KXF(YTbb-h1YJ_5LLw(BO)@*u$}9#_H%Qr3BZby`F}tjgo+hLE*JcI)v<3l;o%K2 zE;P|cX8u!X2ho^rQ`L%P5qnp}L_Y9aJy57}@$bO9M^g@dmom}tNCeJzFVL<SXmST7 zE9_j8uqrVe{yAghQot5M<lwUtIcG*4`=2iJ&_+wq5R5e0xGe_ef)D{KP_nRbWz4JF zD!e=tvwtjB(gyUDh0#l%ciyk_@zIQofQiAk1gZ;AGVQJ`%N_*I8Dk`bpfV#AM5MJ4 z1XUUVa;KdAv%y!V8~k(;Oa`tFX)UmQi{+JlWgb``VYGRGIngkjB+1$CG|)bQDuQBw zNR&um>$(zqhAgF6abB@jsVozYpDg(Hc!r8D8h@hG8fY{c#Bn^Y24<SmHg(E3=No)6 z;>dEa7N%}MPprKDTFf(#B?Je?=z^lO-Dk4q=0=pOlYx)U<Qy9fl$~M>)Kg{4CgH`W z5{5U#Bq~I*c-`nK3PL;W2IbT>#|P&NE>{D6iIC<(OyIdEBA(q*;;tTxRfvsrRkS(+ zn12Q#5LC#W^5vBVpI`Jy20e*zqXrw6D=$0}^XR=L%0`G=$;imfLN?cmqF5Nftg+Bc z#$!{SBjXN<1WYpu_G}dP4JE8yZi#80+Q@9xFNFb7RYC~!rsBdYiGf;B)<w$mLlL{y zm#8F$7_bIpfm3%1|7if$+PZVD5RuX?6@QWD;+a==^$0I*j=5*hVk1K=?adbrWo_jc zXRWQz+YwDnOpKPx<;`iDs)(3&B}Cy`rktxehWjFVDiM)@ou+(o!KrFUl0@=6ztG#; zd*8gNSXDoX<5*PH&qX*`7Iv?W>8}`KQDOmuHs`zGTvffbRz&2}jy++mRvR?N{C`-f zRPLUfoGe5np4k($yCZ3(ROWei*u1DzDsi6YSDbU(`uqE@b-WkUYPB8Za{22dNmgFJ zeq90Isl(|KK-AmYD~(2DJj=4Z0|Nu67Nu)!Z0!CtO%K=Wb^mWK1ZkRvJkO6*tJN(_ zx)}lFdA>D-@VIkst#fX=jWXue8Ynh*%v$S#Ka4ToCrNVV&Ly;y{R5YEO$Aa3o?QR{ N002ovPDHLkV1k3GLxBJQ delta 1446 zcmV;X1zGyS3AYQ7BYy>cNkl<Zc$}?QYiv|S6#mZ4y}P&VwrqKXShln=1j3`#Kva-m z5r6rEM3Dx6_(!Wjj4>EU#Kd64AS#Jy!VjY{K~2zz!H`Hq1Dcx1BL*L(fRB=pwoo3X z&ux2m@0~ejW_L>qg}qBW>2~klnK|D#-#IgLhESSO#D0wb!G8qM<N(bw_Z%5~+;}tA z_^{(kv1S8$E2AQxl6=zS%%Nw^YDftoA1C5v0M{^cB|v2nlwczE0g)!+%Ua`<QaEtw zQrq7J-?t&qEP?&1wzhGD5VVtss;x)9vyc+y+-t3UfZy!wY<nY*VMy=|1Pb5{o1}Di zGl$TcL!v^Tynnao#^#UNC_++7hk_t@x4ZlFX5cjeyl$1CiLx(R>^SmwX4d((iOEC4 z(qKJ7#i(RLAsjah)g#^AZHEgJ7P<B&`z-{$NF)pdPnNmbA3=6x7$oJGyb&#*pv*#G zbMDmw8v>DQXbp4Ew1REGX$;%|sW3o!aw=Bs`T+GymwzGK-wUM~BuZIbB(-KkWJz6J zV=^BC8uO4!El3h!rb*%(lan>W^U5%N^+Q;)XD^m*+=@6cvZEs)X_k3Zvy-j{!n+Er zL;*}|s$fRh%^7c+8NziDj$|>S2{WF04l8$lhSc1J$PD&^K~dDOj);hqaw>DLny)M1 zd9Fu<*necKNl0Xd1|cCJ;{?Ai3pKY9jobEN>ADS&%174sL87P{CcKa`EHAteg$JcV zvs2xaSw<1Zb<uzIGTL5!31!!M;Y^7^r30juhZ$?PV#THpF*B7yCf%D~DYs8hW;uo{ zicv0*kZ}*^jvm5K&#VWZJcjaW*DjXP{t!I-L4PdW_$3-1UJo4%Aq)bmS`-tJdRZ14 zUR5=&^>pLMms{}fr|+T4)1Wd3;jltFQ;pfH-@?i#cVo)5B(kFek@hYTnSe=D6~gsA zC^wE1d*8>|cUq7bJP*ecAgwXnr{PUogB9!cp>F;v=rAZsLZC!5yP%W;Ze<dGwH`uv z?tcg>DhQeZM-u%0ETmJ1dH1$}geqDYh*Aldb)OkdBMyyKTXut&KZ}8Wh5$o>P*oE{ z-_?Ei`QQs+c8i{!PIP3lpG<!*Y8TyuMbB@Cu5EySnE|CC6B6a}3jR5=1AiX-20~Uq zI<n|V>O=*%2arFCB~Lt!x|S^%<}zfjWPbsQAu&}V)BYWff3*YGy1U_3B<-;u<rsIO z;2TFKR?D}&jT!6KV&Do8_62Y%V<=NY=sK_qXTJX&3NFeLNocJi&1(VyX37Gr0GdFG zNFdCNVtV}?%zSJuhK52wOOz*F1izfXsgJkgQpY*O5=pprOD_g)YlQ411RS3z%zxZU z6(J;4+J{JZNTdT?{A3T>_J0IlX~e6hf>ju$6t+oE#3Q+%`z@Fq=lhvK&vVj3m_up2 zEAvbUf(&969x{KN!LPfv<3ig>xQQfUE?MvhW*up*10w1xyrXf<bZzb8<3fo0Ial)J z_EJ+uD1~^U3iS`H!i66WBRxE1e}6{=E|@kaq1L)H9v_|8(a~YlrU?}h5r3Vph_2UM zwmgw`?YAHN0vQz|UXdsj+&EIkHxa(gf!q567ZkAHbLQMvna%pY2_f!QN*Sq4r?IWX zlwww#(yC-z4-yHNnFoR(T-?)hwkz*fkKd<k7*eT*1}Vj1B2kmeXtOfz_<yQpb}wlX zm|3Tp^`o7gr;m=mAJ7d0Y(sth{5q{;I{~Z_f)b_Q%C1jdYc{bys+4-=;>Gs1d~T_t zHzNS3saaU-x^4qArvR=3U{**`HbfX^rXeXsk5aO|tLxPHeA5K*+nFIz@<DYc{))}U z+;FL>>8kCQPNyTqB9TbgoF8dzwf~Kpx4g;x2SPnh`shU#;{X5v07*qoM6N<$f-5|t An*aa+ diff --git a/rust/limux-host-linux/icons/app/512.png b/rust/limux-host-linux/icons/app/512.png index 4c1dd60062d42cae5c89abc046e56131268c4713..6274bb107e8e975a36d4dca438145acdfbfb0890 100644 GIT binary patch literal 39969 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 literal 35016 zcmeEt`8$-~ANM_*8T*iZA3Gs?wix@G?1^MevWJj$#xAmjQe?6(iLymuZ1G7#)(Atg zmF)ZW+`iA>@ci~%*Id`koSFN)&;5G8&+ELmJJG~gmzsi&0ssJNeLXER0DyuYp#X{u zeA@{gIRb!$RDCUVi{RX~(?`kte_qVQUu#;=sb3$pYsuY}klClX7pzZS0{1QJr%zxt zECT+chnV49tkW=kf;_Ao|3QV5WW`YQ_n$}hzK17dN<y^_8~DGlJn^J?rYy5R<3F&X zT6*KAkENaASjKTH>pHW&i>{8gmaFB>o0hIBZP7|OS**5JH@lA`*0(<4L-$*0HBgwC zgCvrdJ%EWpYj|@h0pME#?Q0nJ;s^Bq|N8$1jkFCA@l659T3Fo9v%_#WmIKR%CBxES zt<+Liynag++uIM?8v={Fd+)vIu@>A<jU}3Bj*cp+2{Z&K0Zsr&48*20QCJzo4YIt_ z^EjfVDE}rz780-{g$$W`^*dBURUrWAeTW_*yGa$Kp=J-Sfsj;jmR@z2CQYgV>*2W2 z6rC4FkP;czF`AN-e|MOjc9JI36`*P3DcYLKmwJFZFa^N3Brh#4kbg2HQbU586Odk6 z*EeF{3O!ofSH!6is6V~=G+sX!$^Df*XE$y!(4W$HBR7pUc4~e3ZiIbRs;lguUxv4% zS<W>%Z=sQMK@gRplrHu)1wD8{XrePcH{FMz_2b5H>V<#k@1Ji|omK+}R76_HNqESp zOv>9e*IzEldEOB*2b>?b|19Y3co`q^Q!41+JP`-lXh|Ztwo=aM3}Zz!w<eO<1`lXG zk;2)NZKOgDTJH>n{>FZ6NE5y}8}pN9uQ&P-vBnWRO4REjQ3B`GIBr<*+dr4ABT0PA zz(F6$u=VsAa1sri=>yz~p5yh?*11&IFfK|p%49Vo(i4YF#7GFK5jc7E){vkn**OS# z?&UPud;S1uBO@VcrewWzUd6paM=FwqPlr1%2?1w@K%|{A((DG-6{Q5!>T1OAoZkW> z&2BC#SN-cpV=ys9O!vJf^m;w*_K1!A<xG44CQT%shUkeN?G8E5kUufL9L5%=(QC;8 zEk)rKppp8_%m!DXIU37=QIK&YJ24-+apeyaZ{-m8O}G{c^VCTk+MopEjo4X-9i#$( z-?=(86JV0z2{tq5RK#-#k^6z<(MTXqOqg3U%{QVC>_82AkYD!&)AY;tp~AI<owYWU z12^%U@Fa4)04^ArAbS>*_LAq#x?+in-_6;lXUOo>Bj(*}Nk1KzfuG_0b>~ZUzCHHe zrJEPIZ%-6Fx`Dzp3c$D;#PqCoS}Kax?Z$Tan=}V~f7<ySrRvtTMRv~L-xrVcjY0d} zlw{@~3mx@Y4xPv!-k0@@C%}BKP92=diFY{4os^XJN6KjQ+I<eGey;@84)=K)%FzTH zeEw6M^UQwuQrnpwvJ(_G95+pO5VU*k-}uh8)StWoz(2Blj|Wd?NBml3Ph!$;J%lgr zwEXKin$h(pR{|OhDTwC?;!T^9#{2(mUef4&<2Z(r-MzMcIh-~#6w~Bb2J;Qo>-!p6 zRUFcNxx|bc({<x{hlZ)R+=<$_4Rj&S0Zv+^yRm;xMvAOsiDcTR(>RH{H9Io4-*Ij_ z%g##u0Ry5UM!yvTBMnU09HB5>)z-Gi^W;`trRx#`<5Zlum&c0dl_9=y2~NJF;QGG} z;Sk}~nW2JSBgh!FpR}n7_9u<N8Sl;07_{^QS<;o&P$5K&DmnmA70dtQJ663~adYvA z9OLrI?(u5qV)bgM@DxG~0DtCaE3v)(p2oZuB<tWy<NM#<v_AWCE$cR40Pr>+H`a1? z-S9Q{!((#H!d8s92!3{C->Ru6!vjY{+@*(XoDVjSQyqec?3TbIG-sWE-!YuIqy&uX z8tlJ2lR09}!bEDInpEeHW{w7yL&bNY|NFI51(y#w=v1=M{srF4_>J4M&gV6)OJ7=^ z-KJ9l)(0qXO90ocBT#5e7>iL0PWNc$^1g!~Vj1|v{Ss&$?O-e^k;BBK(HdIJ`ZMob zv;Nn;kTD4PpkxcYx;1%Xy#mg9Rd?dM;DOQX)BglO1CeWh{Dta$KLA%+r3}#zzmXj( zlLH=2&+fs9<JtBqZ%@D*+^d;MY8%^klZFHU5<QQ<T}^D%$8&@0w~bQQ4^MS<B*DR* z%W?nep<?G5Xo*Y15Wi~IBoYg_sp3vFPPIa_sE$Di(}7T{oolxLrn?YIz|&KW#MO~- zHV}s#HX%}LYf!CmA2>b(ulu)i#bb{AjF%3)MCi@>kG41Ka~wPi04Lz%vNfW1Jys38 z#-^JXX*1iGNUa3?J9%+dELD0$2M$Yo6^V*8o*hmEr}`FFa_@$J1ve(<>t}{`P(y(Q zuVKFFRJb-xuM4qFKOm91v%knW0p0lJ^YZ%Q!)*|}A2xqocdw}i_l^4tk!T$=BdLkp zj@@eDB%b$(+(Y{zvzIQ|we=k17GX-ME4dDWTATDJ^&EoB@92?)I-~&`SAYD|MPXX+ z)1JIMnpwA923St+0){6!;ECt+cP68C|CmV$&{18^cWwhGJ}~Ac<qyjfpB+p?(l!QW z9hURa8euOf4%j2EBfznPB+o;>81zAl+{TrT_=6@&EW_(pzPq5@){?41NIoo>f_rWp zg$w(4e*3cK;qE=q!GM1u3#h-ok#%ijG`@c0571h9KBPGR46SiuXgw9R?mk6gKY0#C zLlT?;{Waill$<vP?KC)U{}8Bb)^u3hEZjW488D<fTbd89rB_(p>(cv4odal_Hn70m zNH~AZdL#s1(EWz;CDP<`@~=k5jr{uHkP8QgQAS#<!D(^R9L|Fz^mc%{!f-wBp7RF` zo;#i}Kn`y1^4&ZK|M+ux%JcKr`DZAsLG@X*YCssc(QeN`mj4K2y{saGBcx)^KNXJ; zU&#ki>m7~7cT5I;6^lp!w1B_<Kd*p_GSq<>Lp&kNU*~Upqpfsg-Tlxp3Zi*2Zsq#u z_Qo4!00Nvh+)%v?4*uaQ<39KDGIUM3b@lNSaK>YMejzWTrhzlkWGW-ad5jaM<~$TE z2(C;*(>^j$JV(*B31#y5e9Pu+oc0^d#T>=e+<nbl4keEv_kIYUW&E4-95`6wk3nxa zqTJb~OTE4rN%h5TM*fZ-&_iJsI_c|vWac<pnFHiiCqmjB0RSlRlaDB&k`MCa)}iUQ ziU*)om~u8>xQ8?G4Gkhrwytrv9s*~Ftmk3in0E>xB3I-->6Q^B;h7bXR`)gVVtH>3 z?58r6aEG@NB?bx}oLIA;FD6H*f-qk-G?~gQYLJp?7h~&HQ|-)u0wTADuKV%4{N7k{ z(X!lmBI~K=#l)$Eh?7gT((Df4aGhJ&v&jpEs1ADK*4=GocE7%WZNN^2&-J?Q@tce6 zGSc+Tihx)t+r6yHad+*995^?ILlzbfqa$}x=j;zJM>3Whk-b!=-B{jUclb3E{#9Iz zha){54N^hX$-7$zkG^HVcocK&YwMsHth!c5@8{2F)HYH{Nj>><H%@P!bE+QArPh3U zL`&PY9j<b)qij9f8gC>Kk980<3yevd5~$$r9y2<<emC2xSbBGzZ}03l{;*hJfhjxf zxh*yrIQc*&cD`~h7g0<dwrzhdSKmKZc~2qyn5?+goMGUbNtKo4vcHlnX5mZHVrzq( zlfS`pC)fWV!A0EK1WZKVxQ4gGu3F=VnAp+lMK>EqJ*0qpUZfx>QRqBL4c~infbM!x z&8LUlKKa+QUH`Ex2|db8LX1>ep0U$uyp6i=+_iY3TJrps9}w}o;R^02N1_WwVgOlF zF=N*f)2K%@z{3hsL$wX@yHM(8%K-PUwb>(1{#wf5<CDZmA^(UtgL0hTy9C#k6jeTG zOj_j+4kaKcCNh95k8NH<B?l|29RIrti4vS8_kX;3_0q$8*GNHR#9wMV@##ONHF-bc zzN}zq>?5K(h9J^SP|<m~?5glHgXR5@lSh{{KGQW!<epy!mnW5t0ooLzW1n=z8hO*a z)1n^=rZ*0><-qHq(;8n<;j)lZ2PZ@FTWY7QW|Wu97HBsD_eC;<ddjjNCw!hSv;5*F z$%{p8ey)>hEAtu{dCTG24M}rK_L?qKyqupHjHZgEOZkj`+^u_l6jf-93uEU1N+G0m zDqL#h9CKQ5c!IT3lAGw0PG)<)2O-JU(uu*+<+(WH$|pL7DaN6Ki{YSQKO;2AO-=6g zZZws<SS9~{5#jQvkv2h#7Zt1Vg*=V7?H}>lo^vZB8;}QV{06Ms8dFlCe-gr;YPd(R z78hRQ_^YbDZ~(m1g6<k1(yc8<k#$pEO3)aza(L1j$ozdZz@V;HfhoMx5#EW3DUhNv zy1b67Kb_s7rTKlO0U363Ahl?0m~f9dtyi4q>@Q`eaf+#;ns$qgx{T?~UC2C2mW_H0 zh1SUR956Q$silV;oLp#}2M=hpqy4t~G?7@=kYPI1hsyzarPwm0Y&3D;n0VwwlWOIM z(st5YU@}z+PV8i!CD<rvAD&R*w`CZ;P_Z3ujkcn^L??BPUa^T+w;ljScCJjo5&YKa z<w~{Sd;78a@N=^Epf=%S2;nD;%g2Y$KU-5a%W??Nr{rjxvGj_rH!~$Ck7b)j#jsHG z#K7-43KGQ>fGyUuv<PD)5vf&Zs>Lg131^{-)1^3*aPUV#<>S=@U|)b%pr-zLcgx|M z$7EgK6^l82cedCw>3b^V2>}r$t1yi@$;m3F7f(JTPjh3^lnqVP&M<wM#BygQ4uB44 zXbzoF(^F98kmGNHy{?g7{_?Hw+_N0G5k(~UoY3`3RM)*->fC+k@>H>Fc-ugk;Cn!U z9LVRTfAac;WC6r1o@WSn01X+T<>{)<Al~<i2Aw_n$|KV=DHg~E#3@W*><S&=I9U7P z{jcP<5u}??`yFNBcwsm$pBi`kgI``oKjxQ+*6=*lg740}ht{Q@FLc6r`J%e~^hvL+ z%4^JV=yS(Oj(6%;BxAp7z=`OnQZk$mN`JS_8<uH<qio5Cq^@_bzRaHe%DywJ2zNYP z^9F=zaPfwedfM{|CAA4%PoFDa>l*62rzGGIK-oLTzDuUG##9K2If#GTdN+Z(0Oo7> z=I2=AfBl!J$aKY;i-2UINkS-Vr1GMwI?rJIs$iM~9ydmf-XyqlPyEOKF2jJAA*DPM z=SIuXazHkuMV|#)09}IC<%+v$_29HAjG$SeAqPfk(kJVM47d?M56**@bq6H6w!J>j z+ORpadS5|VTt{RxL?yF$BXHG79d}auJXBg#AWd5=OS{T9DthS#Iz@XlK*L-`FYw=P zvk@g)dJ66(@3#6Jfh5r`>nZ`NSdH@vGVImkcwW;w&aE?7Wy0w4OP`MvNX1VQ(j8kz zbT|Xr;OEJXnARsIDo-Z<<LXFfnfH0c$8@8>O0>)vvO;fSP8NEt1dhtFi;`!TefTvB z=4-F^{X%WGV;zKJu#!b)z$v9+*b)vm{6|w){d}*?0@pM*=qx#jxa)Op$vu&2-yHkM zB??o_&f||*v>LN4#W4t48OBMMm9si%Ay60@ok7>=l9Uaq6WD_HF>i0b<J+@n^+MDj zo$c~$y#$uai!o-50ml-t>#$5+{?7gsvf&Cad?Xg$X8Pg6G^E9pu4TX)S%{S^UxjMv zn{Y5G#*L8G*rlcA@ZS>=dL>#i4MBU<L`*TOd(%`>RlkHe-1=b80DZrBc!lHVR#=e& zX?njMm(ElM9K5IWjK_ucQTE1mxR+p6vNSh?X%DYnp%NnamJ~4NSs6a0(0k2;aVfDL zZzxL?ah~MF)t$BbQ`Ou^n+cUFGlNg8Ka?7zY4>HqX>t-jGJD2N|C|XMnFbbZ`PQP? z5`pKD3Vw^s;e-kK0!X6U?S8=o{){*SN8vZ%S2M_{nQ8bH@#F$_EGj4G9nsV)3EU}? z<fnQ_q7YnDXR>PcDpjOpK`CT%>zujy+%u(}=z0P>G988PcuhmIM{rue(IlP8S`_f@ zxY<sUsoOQgE)iMohE<AReRSsu8&i+AJG96aOU>jiCn8|fyE8oc2QtibopG28B3VRA z@R6i0{|cm8?qQXoRvg=BOmBu^0&PA@v|oCa?`{Sl@wOjdD?m7b0VV7^A1#>KyG%d0 zlWz6M!^PRZynz|mHx^n&7FvaOxC1F`<Z)@`LUAH4AVjfKt$<&x^^@WNKu-vlM@a|7 zD*^R+nsfwbk<|WWLk3{c)y5EOhNRc-4SEvTj!Z{ejgQeKf2JE?8Nx*`*<-S0Dd3&m z9ll?Et8CP(m9xy|W70$#w1@uzdb-&}CM-8PQ7aA>SwzFbA~^~4+&{NJn87rv-TrNx z35nl^{)NT8S%JwE6K0u$id2DDbvQG4aE)ZDg+iR~_htIDOzhe2+X}~o&rW4}T0Cr) z-S{`uJT9xKNzWk*sWlp6%f$`&?;M>v9@sm+U3f^1Yxz9DSel5BmRiplIL>y${y9F9 z=ZQ|#(y@MfznYpsnOGP=Pr1U+`;LG9FkI`GNKE%ZKFC-E%r;zWM6ZN$?2lXH)Xj4Y zorZUG=<tCv?LaR-b{>|T_Q;K;W4%%e$7UEh8@n3mf61Ty>Xml)6HYV8s@I=CTw9%6 zJZ2rEkd19J-z(6+ViG);qhrkqIMXx^cT9?5kA-?FGzmY-%A0X|mECRtX1H6lB<+3> zVD>*(j*gCS`1|3m!g5eC3zl_{B)|uB+5n}QxTK<Z5h~dom`X0W09R2V<jNHGHKmHN zK>A~EzKk#BInYguu`+7C1yczYzPP&;1O=rGdIvlY#$27kythsnF#i#v{x;5ibX*B2 zfbf(9IfM6FS420QQz?SIu6|Ced8JtoPuW!X$_7m&V+bV<tP)SHQTt3wqa3hwI+~t& z;e3vYz;^3TxUIu-KW`UZ=VqSnXjIcLe#uy)k1)w<?pyF>m{F%s1*Af^<$FWLG0i*n z@ME2jhu+f>Wwaz&b|Up30%odkcgN}bM;PhRa2fT1@KX)x4^Q-@<E=#tSD{8CjyXa+ zD}V?+RlH_}SK1Ju@sC6OrT7cck{=M1(Kd&x-y@^WY7R*4`K-;8_4-;}J7Ue`>|AKi zX_eIsm$almjE1Iju#p82;w0EWxIQ~u8;VzY$LaAtadG83o(;ph@j8ycw2I9M1Xcgq zArm@?MsJ?;-6&u_&-n!>&@}q?)~lziy7g!O?WjF~zx_2}WOZUS+D4vofBJ9KZKA{b z9GI&t;Ro#12u_0`Sa=vwsNW^M?OtQD5={H9OTwV^(KH5a<rV#1fC%a}D2@<udwAfA z=+kLO@CMRCm%n@2!<!Idufu**X-PbWT#u1}?^JMZD7D%A4P`lft%@@*B5q01XvttF z5l^MWK9A8E7oiRH@eVY|k7hj2v4?++iV<Dp^v%;;9xzmsve^BXT1Zk{8uwmq#5WXf zRuB@`8Ny%Vh+eBYel?N28T$ixHZfk09+aSWc_{C>0zZV;t8Uz7oJfJt`rRsurp?T( z8A0gb2_s}$PNrG(2|CWA6g)job-@$_-AJla(%TR8P1i~kp$;OWyb*=7qusddlXI=l zvq}2A5UX}fJb6r@`fIvrNaZBqt}FgK-FJV~ozaJQ2c<+KCuDknu|9c{H@I>AwSM&^ zl+kq}ERmSCC^Kg4CXa@97U|*7DDM~d0|B>_@VGZeRRe34=lh@ax5n{veKhNpmaTSL zy=&Gqh?qH56L*-JICMXQmrJ&qm(xIoCy!GHrloH382%@VBI<Rsk`yJJyvZ_ZcyBx3 z70FY;eLQ>+Ep*d%S6QlHLisat|LdWx{XY62{k58N9!=#XQVEBfhq~-XS`7$6Xf4XJ zP@Yui$&_e)dz1`B<10ggVDf8?0`|1Banow6k(-4>BnOO!cXsPp(p9hTBa;0zL<@>5 zX_b7HXRoPhZqYihc3tx2Kq!3aYKj<#S#u<ir#OouX>@5qImVAH0@DvZ>t7NM!o`;h zH{8soODU!jnG2@y@AK$wd-S>}2G)*yj7o?Dkd$<O9`faq*VT?eP)f`~(m&b5vHE9^ z4Q3n&exw#-cD!4_7B5zY)+h3Z%{w2iUM1GS)>PfO{0Hv}m+DkGMc^F2urV-fDKgBC z?6Ek>R6a3a8B*|{82#Zj=Ngj{VHyU6dLp#<9vxC&hpHB&e|A1OFSS3QH0h&s5rBK> ze&ehNq(Tvp5XEsROnOhH5ghR|L_P#*W5zeM^tHO*V5=cp2a`YzEpMhjmwd7t@s}q< z-{n6QhCDC6VO|)qZo@P58L1O2zQp;Ow6sqG8mcPcr_4T+Mdgk!IB`G0azN89EsC2( zIORNf(ck`W5S=I@3taZWVbzb6tN)dFSB7~oIA*|d*p-X9Z+}M_$Hvi9mj&VT^I<PV zNOQND%6+XFQKFj!zC;wiiAXNSFXFL5S%RCG!G%S|{j57Bl>UJ9h75S|)y9>_Ua9ga zQJ4D;kyo_$M?MR|>BmOuSgmqdo}}0SW3vX0FCcvu9qFv_@TKq(X-%Kw5-7t<qOQNb z82?k0uDPDeqGc(ueOt&tMVRTTE5&n)$7bwAV~EDvyw-lf>(s-4!RX*a>?bv+UZW8Q zf;uf8H$=noA?B>D@Df3DM{rMFo!M$*Z2h{kcdM5VLbfqzMYiV7Q%i0KElsSlB7~E= zEC83E2i=z@b{@W|phgJ|ClvdOvZY1VR(3@fDVTIcQ|UW8*Pt;AlQ~K+W&3GxuV98J z-PlrS2+PjL<scg@ui}fy@ygZ9+Q6I6*q<DT_|c}vPzA4Fv9`?kK(1U4$RCgSJ-vlF z?f-<)CkoHBlpb);1=FVon=zuuG;dh+IjrzkFOti$$$_ERNQGqsCH=hrg&^X{e-asg zM4)v-r)%<O%?lrHb0R#9?SB;?%Z#Ph{9=mrotXAyR`CJa#-UuUc+itE5kU!>3v;d0 zeuO(^Q9M@A#!@1s*suwaUf*SvfuW1jnUX5m2FN;YY!y?*haqOGduGMO)ZRatYL$45 zdpg4L;Rt!jok`DtFphkc$3BEhU3{t+a}^9@cO70Q8<1xjr_xcyXGS@+*jNgIQCcz? zdS3JYu66fz#(wGedimM8w=OT_vj-mpV)*JO8-*`tzefor2gBiKzeKu@PKMZ*KR1Yq z2$Ds-?_B25#G*<}r>KdyA>M((qtZiEfG~-;#LJc0GgRqg`4ObVvH)yx2UJ}n%<XB1 z%%Gn-s{{AYA9reC1vMBq<@x8ML#mJolI_;-kE|}r##6T)Ge`=8Gb1IuEa@V6FVfN@ zk}S2#onGETS(tP}OL(lFY>JyoqV>FWF``IPk}c`=t!h()=V^V;lROQO86L{3kEH#s zqGD%b&$`LeXmEzQCsky_9`~*$mRA~(D~mvt6nUN5Y<v27O`vf{Z2NuKTV_y7jF@<J z<s{BpG+s}`23eTUeMy-4n~6B%XsahPf0vs4{TF;d(*OJl)^ETd)lrKndzB`HS>yPr zLt{lY>|+D9F}>8r3$o=v?|4P%O$<G}LY#E3hj_}d;UJM@bU??u^2QG?S}JX~!Jb8d zLTHLIN;1&SgKX$JDR{m1;@HoDZqPX;bd#H~0jF=>5q0qgx)w)&*Kp6uKR3SloJRp| z@vQyz=gzThn^Y3A$L^(<ePb%q_wm+H?*PiMPYjygcMbb5B^1}9dYlqkBi??8iVB#} zRA#G})1^s39wX`I^#@&_mc(69;<qW@@k-dBN8{kL7l7?<t6gc&uAbE`Qs0~R;@$CY zXNY%iu06foM{-}1E=18&3(MkWXOUbI(EX*_ULCH=m8b$<YlYw^g*OM#Hv~;pmCa2d zPhW9KXrXdA|F0!6-cI|&Nye{JHG98;@$F%Y<(;4yeJ#8oBaNRqB8>a5j@=l;wWYU^ z(ts}hQsyC^`^=d=K3l{`oChu<i3?xkdCNIE%s8Iw@t%h;&$&^_l~bayiqL)?O>HQc z4R8tcGz%tkVg4Ut%V7B0yv8*7ge@d5+xcyz8@lQnk$>|@zv7&;wQ&IEo6F}Qv&EcC z%Wfstncj=j{MRnUIk!>p$oRnvV^M4|xrz<N+{~6r)ax7Q>%Y-Aew$ny_~o!)F3KSG zY9T8vZ3JloS{LPW`2;iKRlP028#@2U-XHliPP&a$d?8<!|C2zL*E#QPe*T$!`}Xp9 zTMfSR;dRjG&eBjnmLHP<O2czq(bhp%3sDdr4)`P|sZx_*0#mj5#w%_Lh@g$`S$Gk_ za6CaEzzZuS`-U35hHEb}&q8e?^^I7EIUvuKMg{z?^%__tM{e$*x7n2wl&P2Bu|#_4 z5+&CoEuB5tW-DXPgg6kM%6Z$b-*jk<scTgr2R~*Feu}97NQGhpe3G!`7-KAh#~%-r zj#C2vcTsULOF>6VuS<CT%nfUy4ecUdelGzAG4=W&7fbG}n=4gi`$1QWd2L3ev!mCo z>eWBu8(1T@n>M?e!cDG4=3|B%%A@#}KIr70Aa5Kx(e~(4z&*rCOFsCHPeq8ubp0Z< z{#`od;9?8-w$H1}omZqg{J0*#`Ps}RnBMM3(@E#SrS1+wc+7P_PV0cVsAAv8t}V%{ zwKorT+G$?J<5KPnReTAu6(!a`ANXmJ+)(w3DC0!R;+0JGV%k>B9gqVY^7NMdM0@ts zw`KOG&C%XVvQvoIo2A%yG-03UyH8e`IyM>Tn-G>f!rl1a!z#Q|tP=crP#tQrDFitf z)@gV{<-V4fwYP7V>h)i7R?Y4Ry7}xKJN!%fP+J$JNO<+Uvh9`DkpG;utCZ*j2kCsT z&3^Fk6BVpmzZWF>xd`}7M-C}g+IV`+;T~OZjY6s~Z*gluVx1|^!AYVh)thVzQBlHU z_(kApA=OU@%sXop8y5h@!+Z=gtr}u^n)2x#r8)e2A;bqprSLikw+@_xW#!C2<9t_g zL-Uu~eL6z-r3bm{4r=x0{kSPC#&F;~HvDgXH{W&4OFg_p8&XA?>{}(Vl-;yw{2yQT zE8Q&@dwnuP1MDhP1LItiwt^a|U<8jLX~YG}iF%{2mQu5ZeuVB7e=&_OkaC$%eQ<?C zqk`tIHr~o&%D?nW#OaHQiXiJl+IR8)#Ot9Yi>*shQ+FTwTuWic8!~ZK^u4?DRX~aL zuMcv^;dL#=#>Xpo-9F-Jw-_{?Glo}+-+x<}Ypv2seFY3LaXiCp#kM%-IP#{c3-N~a zW)Nt+`zUMTnMKQ(M}6AVA^h@xEc4<dFK*$kSyuO05NkwrZrj7(s>#5F&8cAdKKqZ) z0u+*g?9Wsq$Nld(HsG{i&77qFTD_TX-{7rpbuNONNxLd2dNRMs&1c<ZEiPdD*%hd+ z@lOKmW3Vt6fA+vH0OufF<O-{M`LJ&F?$_CKcvIHhE+ygvY6Sye8ja@269^b$c~QAU z&Pi4ZZn?G1w-3p^6&TUY_aP`6$HdC7Owxs5ev~5@Ch!{`Q`7V=PER^1p@LfPby!Fd z2Y%zQd~$$+Cc_x=$aoqIu$Vx>BjyU%I3L3w+IU$c-E%i4hAmxkk#DFOLek5TB*`p_ zv4hPSL3)(QEWP@g%f?fRikF<lYFd6d6dVG^JpoFMrwVt*o&34TvIE;gC=cfA>8OM2 zvbB^xMf<9hb3hV5bF}@?=FL)XawtOZ4#|T_yPxXIKw8cNXeMm%e&Mty;a0U%rKx7A zmCoLokacnl+6Ss@N0Rl9=@8ggtMl|G+g3W-x1*{t51#E8d1pV>>+1b^cCICzfp~YD zA$lmVeO&qmfg<cZUH6x<Y%b9{0vRRe--u!xL^nn<7Z)_IE^VCv29w_0N`Q;2FeAm6 z54}Xft7OW5fkXF4z*pLISn|%iN`o@Cf{ddJXB8|fAZh$^mTPj~^SqX$=V2{*0j2ZZ zM6cxOkyWD)x1(=qxfvio>eGU*Lp|AvC-cM|1lL>F^h8y88#3p(D2KEDx8BTRqDZWn zTz^RC^Cc{!x`sC+D16Z!I#Ne$2iA08{Wgqw#*|eytp2vC=`){Fiy`PrSUepe7l6*g zClHaJ82q8x5bUp`wY9atr|xI89DjG<asV^UnxN{!_i)L1*!?*kU9q*Ot%G?$*b<l8 zSkUYB^Cm^qf3=#H>^$@8i6e=R;d((>1qPi$Og$u5La*0L1Bv^}v1>z(y;BV1r{}5c z@2>sy7^UZhb={_0bi!Ej1e?jof`OIfUk0|uYLYmaB`?q^g5#<@)So<!Bb!}~dCnMj zM+BIXx`Kk(btp>Ozvr5LXm0yO!yWdmkGOsf#`OlKe0jS<+*}*R4<#&-m(>vuMR-Tc z9>Zld9x|cm{S3jKCG!WK;NsghWquzbr(N@dXjPliFoN(@)i7ZAr-XffUkfJc$m1fH zYwYNkwAtvKch$je*FHg1K@n;d?Oti<`-j1IM@WU2ih0J*UoQ$vh?KI$krzOwxi+3K zpsa8=8j9>TR6G8c*kPnr2swQ6ojM^mAHyQbRu6I10V8MX3-c?US|M1739GY}Q>N)* zKbrQ8^~eR-UUylY`hpdY!v`}f{DmyhC9QGaq*~?y7Wjo17$6^v+#{by_rSey#v$ho zZ=)P7It}rIdpTm7NV}`@lyFT9Pb8T|1@TuXac@$(<G;*R`ZT&u|9e8BY&<DA(5FOc zjIb05o*Rpy`4?OXFsv)ve=>cmvkB0EA+JVDFXGQlTThiU4u2)KG-II$)ztj9L{qpM zV%G%23@iWp@udc#CH7l$W`h>XXST@ys`qmRa6-L;w-3X&&GH&Jm+Gjd5SrLpMA)vl z?;W`6q%m*pE^4SKfZ{J0oncN+$mbI?UkLH|a~7#$cc5Co(-BcIVZj$@=q4xL;t*DX zh_Vez6+ouevO&hE_T+1NACo1K_7{l95QHN+GnN`T41f59R^jev3e|ryo+dLszH^t5 zIxGg1dIY>gkfQ2&d~fp;!$i{o#hxh@lgv!wDSGVcc-#vJSmgTsRC8hf4k6et*+D_p zlR0xG3iRT)9KklcFiU2_BwSfX30q(ApL1oR>u-)9M>OxJQqd{UP5HESK-ZMO43C<$ zew;9<wHBFec_u0CaJ}TlhdM41in<~?!GBbB%#e^#i~_^?^`lpO;Yq<JoCovlq%g+d zPkGu(@1qZXO7m{|pr4Ll`Adpol1d-RSqrlzi3s69Vo(JO!U@%GaxZO!h=Y`AyzN$n zDTnpfJrgwt%P;~%xY$Q;g78{X!ss`v9UlluyPR=noPy-djGIq9`A+wYeVP;~-hQ4f zCnL@OdkgpMj%PNYQlVg}8h1FS!pLRQ1@DfzF`q0)&?gz{i^X(+wI{UUz0m~62hh0a zSQj+|@=IIopecmuw$0wGG!&7SnS@VO$YTp2FH^M@%2ERS*;lHr!=rU%Ny4byIQ%OX z0gui6huYpuXNoi^mLOA!e#j-3u#Y1(zfg{;R;oRd(P!GXoNlTlx8><g=pj!Q$ZKj5 zDnD~|g_$%JB-WxHSQyhhI9Sc$+6X8Clg+AFLNFRLPRR+(PtoArOQT&2@vqz&{}zlt zvuam*pA<TAR98{}S$V?gLT{?2t!`2PaU`$~Zo|bM?WT`@3@4?r8zW+7Uball6n{V0 zpexW^imHsM2_J43SY`bCVT3us`E^(H5wWov=R_VHrk2=LbqwC#LJS`mRb&lGSc~Mc zIwuyI{-#R!B^a9*leQmE1K?*l@qXoH95|~&yp>%E`B)a8?|LnhQvVB4v{989<|m9^ zypvpbQezJmzj2OGzF$q+f1&KK!n8p(^|CuGgzvYWf;NxXRH~(NUE`GyyY(V#54wO6 zkf)3(uuV{v8OXotE0^a8!GVy?0GjS!*A`|!VR;``C?R|$MDUXCc#4o|31tN8(T!yQ zJzJQIb9$hliH&`stYhP_J#^1)ysx(*Fw>P7MwL*HzXa%F2$gGRM2Rz*lYE3!+0F95 z`yDe}3WDuEj0X><T!WV%I3bKvx}Jvse>}E$jIpJZDu+x8mm%Ei{OPx+zvn^qyUc&Q z4o+i08YqE^uXm3%>xlUz1okPIe)e{Vd6wm1VtZ<&RA4z<#`p141qJ+WnTh*D_7zVu z$NS8Gp~vb3K@rGysoOxK{ljjR&(Bk}=#DOpQ5n5|5jp!ONV%l%a6us5JbOPi1<cv> z>}yU~+v*6@YpT=|7~uQ);jfz!2BkfyI0}PnCPgs<Y&;ig0Bfi?bkZdU9T@*WZr;nA z_whPa1swSPp8UdFA`af$bQvBV74PjR;Ixq37qR}X*KyAJ=Lhni65p|J>m%acEaew1 z64hgR!+#li-ir)(U_i-(yzD`G1BC7HBw2_SlTjvmV-MYEhk`Z2%OxP;sw)iWD&!a$ z+D{P#eST2Dfr=q2gHB!<Q6%B8J$H{K;az#wsL5+ju^2aIy~IpkqA=Rxdy7O<_`rP2 z9Acv3A$dLRfV0%#uan1y$DB_Y&Tb)~pE}O>2IfX2%2I0^!Geft2t&u0Rq3dA3Qpy3 z+?g3Rc`kTBv&BY7B^~QEA%Ycg)DVoN0WiPFDDH3a+`sg<VNy76>D}Ijc_;bAgu35- z^2-oS^$h^4&VKLXW&p*Cy&Re(%k5C)J5Y@Fby2&ziQ(-3P}8p<M5O#9^*1957Vn0m zx+e-#JB5B|qgMHluP%6G%}A~@FQQfOK<m;+VGi!HN6_{(3>SY2?#H}7YBLTL&t+{6 zHM_)f`73xfx>h{~K^H0IF|RHM|Hyzc@WRq6P%J~+^^+IxhOVt;of!A{dUSS%O0MwW z9vs_n+ZDjK4x@MlRDe650E!Iy1L4Q9RmTa%i<bO6+5c6TcDg>p6(OQ9n#LcjswyIt zH4>CTQ_GPQ;9145R(ce{U+hvajnTeqFxM!x)O3HuzS2ac;l&5${RpCtUe#xjrIKyo zGVwJ>y)o*cNqM6OA1#+tr54wRH<x-ztF!UNYg-%#D>raRTKuu?tp)mg-WOlW>#&Ex z-0@fPF$^gL8!;YwDJ3#3o#!C^{sa$z-Wa|5CJ~Hh3Ebs_Nv%!0Yi){mOG<z<rARHd z2FVeCH}@*)hpONBI~N)cRq<e$raL~%=FHQ%G<8GmyJ+yOD@4smqT;<d4szB&nPm@h zZQLE?!J>$71Wnl11BR8gV^IMfiYPGiRZl5scAw!!Y8DJl!8Qb3Bp9f`xJm*alO-@9 zW(wBv6EX;j7G=|AuPrYZ-V*0&Je7Jsga%6{(~ub&T=tKxMHgKE_!`*=A9B6fU?0@m zy@6{<XIFkv1y^l&?E-vD@3n;E#xW__fwJofaNJX8f(;p$Dzrg98_m>$`})b9S&Va? z?}Z-0F9JM?idC^)(Ub48kAj@N0L(gg4)lf=!HcjaVwnkK`gYiV%i{8Chg@VW5@vn2 zdJ^B9<=hxh|MqbOiYC3S8I3o)K7Nu2)~45SrBD8}JUFiQnQ%iznMhq4rFQIZSf{Qn zv=fn%f1{0y9I{5Xv0%t2w{cL&|9vVoZY^rj0h0*rq9@P%Hh`{(N2A~tvN*8x%>sI8 z3v7av@-M07EC)GcKHaJonIi;vu$PZshu`h;WmfnH#D_bV4JKfMOa}esh+I0se<>o3 zK9UgU4)ld;i8h2O59fsh<d{c;77afek7mxyKUgvE#(}d#c&8h4vS6(!o5{8QU(35P z4>R1$aC%}Dvaqk2Kp~`S{X+J&LP(aGK4Y%F4VDq*j!-YBbjzf4yJ^_#t*WEMY8mb8 z(p6JOX;tr=X0CS9+qcI+WbBTS5bNMfu5RW^Dog+MaN<E|`0eJ-=IU;dcXy%>o0%v- zNrvfzyehh}oe#tw6FcKsT^hOhB>3XYtI^j>&`*R+zj7CtiIoMmJC*|G(5|Kbfv~(B zsBSKUEr6}L!kUULJ@=cz#7Xa)HJ*3xV~TNeaE?9PCBA4$vymlHU_nl$*DH2iu-)S+ zI`CJ^<ciP(7Qq}&(!<C2R}u=qopm-5e5zWKljGg~=R!Qran4d;^NS^W+8YZKrHjTE z(;=XIj*uz2c^TY_O|JcboRjq^G9dmwem5`G0`JfeVK0Ir)0QEWQo@z7=I*c~E@K>X zLjx||?k7hac}it2{7=Kv1n8|lw4U_c%)U}*u0kEXD`LhO*W8_yM0mL@B%o@?QoiRH z-*t7S9~*pK@3#{KYRg`6fn_}hg(9tJ(bv4)(FxTTyHTzb-UUHhh+_50_cN1y|Lve& zR+T&^E3#@x&&}_w5Jr*Vu5uEJp1f*$+(uP`=eUY>E6{vt*0pDYH(dB9WPtooEOk`q zc%EJYNiB<dOcsNSpbb8QE2tq<4~^+_>iIi{80dc>z{Ve~S$?`iE-(|mOr~dmr64Po z$C@dCwcQ9j$b#HpQ;G`~bl=_TE%^ziRb_5O_a>ZTBAINU1)gArredvyr!-@$eG-sO zi!*jgu+OY>`ll2FsE)gvdbYuLQswr2Je)ZmK>zMu+WG;*Dp2Gu;qxtUd8zp0{Y5(C zQgm`R2(9-6*7z%J+G4LvM58ij&=Aan32S!j2^8c7RR?fU@Z$N~?l2>PcGs$?@3*+v zciEcz*~~86>Xl|p&_Gw1YGxr7cGD8eE&`l)YD*~NY|gkohYF(>c3#cQ)>4w*zlzb^ zA(rpYZXR~q>2h*~a>lSyCjfbZ8-FRMB@P}i<gDo?2)<89eC(hUUkG+(W$W>pDQF7c z)+!ox@d7rfFAU;!p!zMl7X|87A+RbNM%yQBFS7hy#skxA?9GQa=rJTOb_Fk69m=W7 z1uun?rM!xh`nj8*XNy1hDvV9!Dl7-6T<z&vD|j;@F;Xu#M33;j7T34m`R*&`mkdV> znL#PBZ~$Mmb~mP2nJ3GV6I#E0iK8_n&U&8Zcdg8CAR#!77^p7Ycd;qr++qB6o~vZN z&W+9r@ISPw8r7p3)x#R@YptT_Ff{Z#q?68VcP0@@y=l!Ex;Q5!JSf8l@Li&|`=<Es z#qH|O*9imW!M$5?zgA}*Z9OVLc(qlQSJolLGNidueU~6r9Wk&I<BH`otOA?1kqz06 zlnbtAw}Ud!^43yZ<iG~W(-`k=?pQFQUZmg{P}$#Z)ZDpTN!KZDp+^B9r^kv?{jJA2 zO-Z_SG9KQYTq4UJ-HC|>I{V*-i>CF+P2R=p&Ngd6q67Vm;&pUo_g?*RBL?9{p9sJ& zNy(Y>r04aV)n2i9hSe&Hx%^#}#j5_oA6tuZS-c=;YGue)u?|&I*oMiqnyRkovAeIZ z^~s+8$dxD8oy$y63Mi=Vb;6PbIk#^P+^vr_`OUJO4fHFMN}sp0973XsNnzuki|?xH zP8SzvO^KoB`(7AW2+G1=IhQaA6Vr~03r-s$iz#?8DiM6Ls7RrUG?3Jjg)I=jEA_eo zIRc<#<Y&7Uwc9L8^Ec>84%CdDXB*yNsRUMxJ@nps@?{y2UQsbpK#r*ilS7)Zp2@<? zj~FQ@XKPj3e3SJ*c*YR>SbY`N6HGG1R~<(UKk$?+EKsfdN^~A&-XxUj7?6g73auw* z%<3W1PS&6)^GR7UqgB<+tD?Tp4L*c?mk_q&iZPP8u*kdpz=|^SF1FZUysT0Wj9gV4 zRLcv8-|)3G$Bul@khalZgXhYM>kL>CuSWHgvkZ-LJI9PRj13W={eG`JNwpV?ifJxO zI)dyy(^;g=(azj6U-@NNid}&p%%3BFo-=$ncjLNspV0ylC1RnavOFrI<s`MsSUEma zF>Yx<OkPAwS6vw7+W_)9n+kcNFvH)1X*Y-McO!_*w71@TwVP<X@CIlRox})EH$yz> zZrr93&m}QBKp&PIKw<PaoPI7w9KSj=+3IF_Mro8LFFD1ARGO`>6udG*Gr@~^?<EZ1 zQ6l~uz|E6y9E9o@HU8t{xPf``jW@$+Zu$3By`t~JuMF^nIwl8UuCA3R7n=*}<V+&# zjWpw#aF+#{WpT~JGjcD<M#h0X@leq-!4-=NHXlYr88`WrZk+RnpUT4`M7SNk)5P>8 zz?$m0R+mc7CCgd9$98(etLG-vHe!EM1#g5q9G}ti=DeY!*5fz*kGrA?nze1tNR7&R zT%m(CQ#2b;*TosJU+Bbn2-hlqmO&%wN?vCfPSmMNHm;2eFi&MkftlKI1Y1X5b()~= zNo(HiZ(kOj(Qnyr+1TLMnenTn^A(M#3sjhpZ!r<OKmDy6lD>CtOc}iAObCXn97Qm! zRB>u)D+uS9i$?hb<+IGI53#IfNrSzX8rK^rGghKOvJUcnPY4tFP4vE@jmQi(sl{J^ zKv8kGuJZ1}zwl%u&}W~}BmYzmt#25k@}<mqdE-;Z>f6~5lKMfomzc&k^SV}RV<SZV z`+--6c&0GA4YhVjVeFaQBtRj8+~ONvw+-DfIhK(@N0T&nqr^mc;IfEgBAIYM&OKPc z>G{06i_OLE<kU8UIvIhT0lYAJMzk(ZkY7yIjbvl|&Z>Myk|9c<F3O=_7#+Z{^=^Li z)ekbaRkCDGauwflFtOu7==^fpAsRsn=LluS4M%j2wvAHOFf+Uu!U+$S0rscjgbkM9 zjcF>s|G<jNqD3i4Pd#&eKFQGbT62pp+Lqir4<Y@{U!#_j4<Ts3kDzI~P%yx$hj{2{ zrFE#Tx-)u4p4bU+WL705Z-l!z5j!Q*9s|dg1g{24C5x~2C$3Q%t%(v7TVX#}r>$HR zavdE$s@z<0_E$4>{580_sW9E!?!_*A=sc{OM%N!AI(#;c{ml@(0oDL#XD!l!a%!+q ztpaOe0q>?OW1w6$8FHigZI(Wa_%H7wpfD%F!A9vqWx^Ea7xMj3JC~Hbcc+gGL3jAs zSo-sqojPAk(3zRoa>sPKK$%`9XE(WwOU&nw7?~z#UpkOdPY0Rz>dP=MK1b%(N`B(n zmaAp_u1eoNhIcsoRAg7<2Ms$^gG}Yal=HvwUEYQlNnjf(xxloq>?>@YZ%M8^BemPe zlmF7dwvFED3u7^lVu>(C%N9a1=-n(TmjfrQrxbf`SH*aGApfhE(^Qv)a>U>ZE4ZY@ z=E71b!X^sl`MUiAm*(hSKWg+>3=~n|><k;${)o+U&G<)VI7O)Eb8;|LRA8j2aKpk` z0`TK%5CdIeC%0Dm)Zh><fazU;l{TyhD;XA&?EcP8K?Dy>fxyIEtmGxVTiZ*CiL%#M zh9Cm`7CIqb$edsX<y$<Qh?z{h4JFEUcW;`i(Ol|PxZFABJ95mwd6aI#lCo-mvq9== z6G6mbYJjV#Mww016my@M3K|O|B|&}dClTBO&X_!H+4~7dM!#xw%4dsbI0Z-nGZCup z$9%yQ45e@-aZOo04k#VX3;SuJPHhPX{_AAQcPeXrs6MNx1>~FirCg%yyu7orJ`(Sd zN#rWrr#PL;d-6c>RDX-%=~wQW{DJ1wVsUB06e3id)IBR#2*J4gyQZO)gcR_jR0!FW zqc2x^T>7ZcPLe^o8D7SU{f7d1BFF6O3!Ha#B?kmNvJ4ZEUb5~LSjm?6v0oQ8;e|xq zZLB=S-z=OJGVMtI>YIna;#JM5wM<X$aWc2*Z+z6G(a%dcaX8%(hm3&Op4|`H$ReU2 z8ft70m7}9Q!ql8OP}7;xCKp>U5d3)O)pb5P9G)1(@@VjKj7TdfqnfURr<gQs2#~WV z^C?0!uQgQAPK5EV;MhXeN?!9k?|0zy3LyU^0qu@!yhQpOs~twl@WM%y$58Gf_tvgh zJ3Z6TY}~$Owa1s=-sgx*8g?vaOq;~HG6o+_5f5!ibF@!;e?}JRQ0dZ(2yg}LY~>h` z!*5WDIO>x>7mqI3#`bz)k+?KgtY|-&>aZ^fAy6^Cu%E)-D1h!#y!Y6*#1l}{T`pa| ztO^x?T~5lqtF2s3Czo?7$*ty|tt!-T545jrY*BuVQoGs$#6Co4ynIaH=;Tmf08&~Z z72hQeI?+}^@|LnZ*_nau3>^b)W@}OO<Y4zcYPnWM0P9T&K5zULRlsGfqNb~oF4=ms z8FEdwg3(j;h#@b8I?sx8C5y9&SN&%`^1Br2I;5<TT8Pv^;<Qa7@a<jgdbAv#mbx75 zK0c;q*nRF>RMSau)&a>&`E4RZ(8};EPd9;=<-u-5Pzpyfe-N5iSI!3$1^fm*p#~r; z0$Yq!@#u<C(?6`k2=XTV3q>Ri0U{Kh?0j+lAzd3M|D}tf8Cd7O9?@j@`=g5SF7S!V z(0ijnab5*X*RFhed@3ls@lw`D?Tu-Tw%D6%XRa?vzOpsr@A~&24$b<qKWotRm?Et3 zfNfND)UAi)CMs~QGXsWg<$dtT=8zGih<c}Wrx+p{(hP!5FO-t@G78*-GH+OU>LH8! zTenwX@<rA)w=@I8FC417AGoeS%7uMRWOb<QE94#My9p0AgQr}_Mp~b0I$1(3Dj!w0 z2P(AQd+=J*_VsD=&y=h5+LC+%N2Udk-+ZLLbDD`WZPLs=!(OdUQBEFv<VSl93kCQB z+ouV4st9JHBBf8#Ug9p6@R;R92aIvP@^kWh!Ed?sq9S$6kp`t;X+%on9jd`k$G~Bo zLwMB7m>5GA2Cp3DY+P;lq<VWal*PL;EtP)iLD?;qy??fxR7sv2vCfm<?v0U8q}~gk zE~g$+iT7luft}K9top!y!xG89Vp_7a6tc~>;otUy=u%X)S9Yw6fuokz*L%HsKojIb zD}SCM3<bca1$JAC$g~tqTK#%_-fikN!)?GQ*vt+p?WFJy`biwtqy6KKGIab_*}vCl zH!qr+wyR;@mku8UFnmpG+YK{*LAKXdqeZW?SAVDZ-rc$GW+2f4&PzPJOo*WHJU3&o z<z}S-Z4-@XKlN!fNe3+3=zxmfV3YcPNH7VDQN^x8v|>IRAz0)+aS`LsMF#JWNH!ko zJtXgS54i_%|1kJch5%-i??~<Fy97@+Jdmy#3vY2J_tdzyxBquileyx2x|!|`O|0e+ zN%B3(H@q_e-*U~=9aez-2njo$!mgl$clx$_1)WemPr<f6j>NeW-y3Vf<@os(5kAO5 zG+3}g=Q}KBdt|-m5-fTMQxf-7$=K`=FEn)ULbm&&4Mlh`h^TwSTR#-yxKgg>ybij& z=Wnb2r{SaH!<Ltw4vKNZUuNCI@=mf}o0H6JNvu+m_O;&5c~?j~iv7Jf1rhbmoG*qS zNm3gdy82?C>pToem4WSFlY}OY!JK`jvjHd41KJyZVWX-YvVxu2wRkNE*uMAmKf1Bv zhc(k0A>qhG3yt#WYc{a&<RlV<TTo9C9DK~X#&72A)l<lxQfs*8GmDN7OzNSnvoE52 zy)M`3pm!+80*HwC?p5q_<ERL64g{M3>Ay%?&#<d8@ojbGxiEd0)+K%L85%GWYj;3( zZ_6lwSb&^`&X>AvN>gM~ljJg~N6EqogHgFOU>l2FVxAfrq5^UC9BBzqpBs3;wJnM$ zSakw^Pn&<2VD;bFMw9rGnjh5_p>_d;!$0?jvR^IFeMJf42vRVWKgi_QqEBr-Cb8F< zGe52a6X)gW^asndUcASWca4~nOVNpHrsa?!3Wb5C6?mG*|13kRZu#!$mCph{y&(k< zLOQfPOUF(d+WbT*qt7<&u%jK0wnwH=V9#zBpmdH;QCv-Dj)>N4a)h?VV?<}8&=B<p zEehi3bLG4NFZ*Gs=S7I`ylk>ttY6)*RAr0!D`&Q(nJeEMkB1fcEO|bwF_@(Trqp7& zd7?k=X}rB4&;#_SpP*lRRrY3hhjm<1JKa{a-R^B(xTrzR`zo;VJL4q*cBXfl6Nc&- zP&QWEtIuund%A{<n^{bHM8fyhss{Syi4VG?yiM_~`-guGqP`GbnxbAtOZz1@jO$9) zN`gN7Amq-87h#9#;9&in<Fz$ax(h|l&!|b`OxVCykHKYJ+Mf&NP%RmD$8gZFw=Avg z^-`FVO}yD@2D6R!|NZvjHz&W_n=Osybsa%46k~lIjCyMQFiNDmL_7JWv_C;^uw0j{ z^;qde<62rVW~XbfM(J2Ytm;PTJ~U9iSbCRhUaShm3X|ooL)y;m6J3dXU)L8bGId;H zI_*ze{h&}hPLzg!Asloj5>ic@foTce3b{cvmAjlqLRa#J|5<<1>lcKy<!ZjHKAu6j z*0wf^qIu$`$l#j05V4Oql?2bT0mMZh<Ho@MY40oFqWZ#iX9!6NrE4ffT2V?Rq(Kyv z5>SQ`lukilm_aEC0TBTa=}x6{29TCcLAuMKhJj(?Y<};1oe$@4IDT_8*Is))YprKJ zdEa$hpxG!`_RSXcrR8BnldtqMBd+y_U+%u6(+qnoLz1e{ZL+ST3?f*fwhJDf9&10` zF4KO>&p}eW%PZv7@eg#^3fQ~9Hi|$W$RZ8nUvGAb#!uyvy@;9GYIB~*k@^b0zDyDM zBN>~#f$*nS_)RV=%H;DLch85So~1uk(Oa4vzSKbA|H1{TyV2&sUeaOH>Oi+<8TVT9 z3XiAZ-@9BC1=Q~e?Z4l&eLpz6#^rG=Yp6`2e0~n6pdd1srpfx`8Rg7&%uZEkeY>qD z1*EdnB5jUmU0H3}9twwRS{bQ_ab3RSyymCtWzspXZW}y!Gm@7-5!B9&>Qyw90Kp<Y zBYGnbGBh-(2W2mPpBAJ$A?(#onMkAZ?pfC(*KpFSghxE(e{?NTfBh8d3mt)6h8W6# zxPT&{3DhNVD+)?M@6XmufJkZzEkC-G1DXzK@RV>XU&>ImKITnKY*L=0N+!^i>rfM; zwZ(&Jr}^4>wfZ6JFLl-X^gzk3Ftvmv*~5~jl@1EvkN%}!l{|~s>wm3x!2aF=?asy? zpT#xET|0Az4Rn}u=3jRa;n}8tr40l$+u{!}hXttFIp{BG(hoQ|;Tg-;^TC}cYR(H8 z^xXU`(eWyGPe0zUs!_Ml4Z+v9A)+U1*hN$4e9cE#jK5v=0sj?H``zE^`eYBCRqw1X zQbZ>BRONn|I13Y0EkF-s-TMO59<)<wjm|<M1~wTVGLX%`)9lb`3-vRvNw4V{{)xmW z-QHt*1j@U*h$g^I4;Tr(aBV`GhG2qcMmUohaFwZr@4@pY67X<Ctv{*HX8+_$zbKmS zp{-H*B+nR?V!+O{km<-qIdkIoBGvMrmbyq=N#srIx0MUM_oJ|XJ@m<+4KCG}BpO-U zT8*VthZ(W`3Q%S>r~aVsfR9nef%lh7kRg%&&qAB20K--wD*jihqy=c1g5I4ai)sDK zR7{qvUgmWd>Isc^eXW>x;hs*_h#<ZjtUk_ZA6ljdz|xi~Ckly^m;4u5eCze^E#;wq zx-pZd(^lrw1U%yZaw!<Vni%MVcK5pn8)!Ny$fYzhN{|Wv+R$0b@B!-}8(7CqMIYe| zbvT1lZ6HHZ{I}{tUpavHK>P#XPY)DC$+X|vt)XXVGS!d7p1VQU(mtUYpAA{r`(;rN zX9d7dzqH^N#FO1!7pg>Qtf<E<8RP}Iau;|!1G+r3vWM9&Q*(~6D6SuNqeN852BCPV z#sQ#Y@U98CH}E1m2f6w=rR5r#wcgNIUy$vwW~hUWE8Pzth7W(qZ2wUHS~nY}qZ-*F zpJ1!>qyLr^!wG&MVxk;a{`V_TJd8_A+P9*7Z|bXLnU?OX*=l5&`91`qnEt{KRVrNx zWV9d0_G4nL6%Y1V1Tv@O4vCg<&@_?f1~MR|>+J)YJ0=e`jrCZ_1<_Q%P78|Rz!&Na z>YZmCe~)MvYnBdI`TkfaKZcYqgWt-N6<#-sx0utgG^RLLZKZBjzVuZxTDa0EI^*>X zB`475gxwc{c(=PWd}VKhrtILsA0CsJsE+is&y`dTfB%d1B6FENwopCct2`1%;jb8~ zC}_PUv0A~;?7s2+D_T;G4S@Y_`=7L#vX&aj7H{$(wfgWfGOjp@)Yn3#W6vJ{6{_Xr z$qRUv)d8w4bi3&wZO1M{e%3=sPVL64VabwLFL}~UiEdiHxi3(5f!>zq#6%bOIEH7A zpy|3gC$ecZ+{bKHxd>IK8#rEm4)7$LW|~OJKZ(w%^@|*vlipwNHSqjNqCF!h$zDao zV=oej%GY>4@Ut*5b`TEyuK-QHKqGSRF3%VFe?m2t?~X_Bjym6q9C$grO}-(!xOU61 zn5%@yd<JFg*ei^6pPO<G+qJ0_`P#MNrBe`3(y>~2Gbc3L1ammYOozS;PiHvMHpkS3 ztjbgps6sk@`3=#TUdOZ>709+U5tX(-ZG4Xz8RJ#%*W;<K{{mm!mgA>sCGa2Fha3;D zCkzgVt{@u;Ohuin_CP@2YT<!Gx@rqmvl6-0M##s)aozrmDY3s<`);|&Ik58(A?ya? z{guo_0f=JV9LGIH4u{83<v8;og^-+EPhV}mJa!!%>XGda3@C5!!y+(f@;@xYlEzO0 z&UwOGUNqyju^s`H5s4k-ERPM+l_dqt{xhZ|@QE~t1E8RwXxBhE{<nR6I#feOJ_1N} zO!TQV{dm7R%0~8qN$;{XmP0|5J1p3ypzkf?^UF0IZ`do@mi}zh{{H%oxdAGY<MMW) zh@p*d#P2?GY-8Vx?3keg2d*+4K7=7De|e$0q{;j50k}gur!1W<jyHIk+tUhEI{u7+ zX+^qkd{0*-{-$ma0~E)>QXe!1em}uj6k&fo`l4fejMGJ#_&f|>wR2BwVSyV&b*<xc z>TCECl7D^07C&$Y>v6ka?|HzPHhrVz(l?_ASz~&$Y?mv!R+s49F3lHN4mteRKEx-E zbEK3I!Ik?ol?QaH*Xcr=RNta4KQma_ptlSR?<=bk_xJ`44hz4$T$lN;216~V`Y=x9 ziW6X4sgxfP{&Z^QZX{PW5~z<X<KpN25znOFYccx2CEMLmQyIb4#Wi8>XJEK@g59)6 zEyx#>%<7id%=e>c6<&j%-(c}EuMzL=uo!<;aqL}+57dUvoIgd)Fd~w)RrPC2?ik*m z*0ez@o@Bs2jQ+Xxc>Z6G{~Ns(17(g2fsFJ)Er?IIV*0QmW1OpBOifx<s+z;k%DQ#R z(EOjNmr5_)?<d!Mn6(gjxz+w4eP?}AUSb9;RN_}>{N5-X$V{!oa2)7LO>+@Ard}|4 z_|MCm691f|))_-~zZsvA?^WIY%<$$((D{|~h2RhE2LgJI1vkEDjGQJiUc~r=Z1F~^ z^nyP)M!mgt@~e8J%B<fWDSMU7_PDBO**>{`8oIyC<L^sWefNtR5^L{I-IP_$&OGCN zY^uIv=%G-Jv{E{GEbf0^L(lWk?vYC5_>Y&!tQDKoMZiCL#JCxcEXApNTCcH()EFQ% zI3j%z0mRFLR>}_!UO}`gS~U;uRKy@7@i%u3l<!=`OKfZp>Mbx2H=(BZt-tqw(mmak zkHb(wgqnWF{tmA`m3(0T@;ih~`z8Mk0eIb{(mksYP}9hX6Xkf<ROvU!>;{@`ur8x} z5BJw}_l=0EyR#S}wJSJx9(Dan?wg4hl<(!@Ex>ZYG%1$g>_dyZsO1CkB=iFO#QU?s z(T#02q(#j;t+MrlHkt;$3*RsYz=Cka$9f=3@we$7>I_NS?zI(&Eo4~CsR<|kvZ3_< zPF3vsVzVGN*|#R}atPvs5tWdrPu<?$7I^{oc@#m)rDUP*?tLLmSim*+Sia}=%C{fM z3}-99P#^VMI|nR0Rhdh_s8k87potaOH}u}JHC9wC%;@vbkm0BR=}kXJhkj4QMNqNI zVwXKyv?HT;KtM^pf}XUVGh`eMb=$eM?A^a9lI{PM>V!^p$aw;;*~a_4V>?LWDyo(s zTozWZ_8op>dL^5CF@R@}<M^(sE#ET|xU!N#gG#l2OndEAwokjl&|h4fRADIfjkA&1 znQd|AcP|}#rV;v__9A@ptW_n;0)Hh^#7J*iyY=V8l4*au;?EMxo>Dab*L1*hzkd%z zE1NScy~=#(Cpqu8U4jk_CGJsuwf03@thq~a_!NUHOHTjLVY^r<EXF_#Q;Jtk<Y&(V zx_+>!T$)5u<z7*B0r~W6vV4T*cr`~5+=D-EpNU2}iU57>*``H>CMvNi4X-a&gPej4 z{PJ`l9{KvzyT3#$YhmWg$Skt{qv72T^4AvE6o+?bX3q6p_WVy6<aeBf&SzpP$NFIJ zblz>SpgcPeE*6wRSpl<pbC1^jtmDY0jc88<RsWRI{TfAW`-OC#tKJ9w0*!y1FN}w8 z`9s&v#Wx(qR-qLp<9*O@9DRAt=%@IHfSVl8NplumA6I><>kfbHMWBm*2H*c+fr&nd z8<5Q5q%C@mT*bd9=RNdOSPzqXQ%4(A@&v2LTOUSHVmZJ5NHL~*F|Fks2hUG{Z2g%+ zzj)<b9n3fOVKZ>^Qc9D~6X-(9eBBw0KM^b_DEjwRrvEW*5?)zP`@AC_o@4Q(B)I4} zIq5<ok{1%`1gt-sK&VWTX}C(2HT8KZaNop^_pvQUA1RcBhYUjW#^R57!+Ut?%Y1!z zx%wbxdGI9!Uw4zl<ODL)Y$w01)@>z#cm3g;A{FP6oN7{U4#(SvOBucskPqY~8RY$p z2>;>uBiC<`9IsQZwG}0v`|zmcUV-AycQUwv0CJ2ICGYxquOwh1#f!ec^n)O`h}pZz zscGtB&c<i%aQs}>7r3p>3sxKq#;}`=Xr({!<a)g`3Pqp3OCG7M8&>38?B^NU1In7w z4vR`r#NTtfMIcP$pX^r?^8Ywh`hOQE>||Kpq$RxDW9aLyFysu!BQ?s5jm6=jhl%m< zf;lZ{E-OvL%m0MYuKaXUB#D^4Cf%-Fg5?uxs*T<464C%F&o3j~<3f(Y54!n2o!n_! z`j$CGZ1=8nEqyZ}>1EyB{E@ge11b~u>wXNb7|O%eu-*KqkFEMXwXFc8)2XPn5H&*~ zNfBCs89irmLx+XD=&WxG-EIG_em3}M5{8r^CW(EGy=yU$->u2)Rdtb$=nI9#h{5M( zE9eNHzoGl?dI4A4Dr8XjAfLYZ24_^TLpfYnLzyOVb!Q=>gm27d0$u$M(npgf{#KWJ zx&!|ui5TXCID2kNAkP%ZR*pvD{19OietWQqm8rsY>=T8cu$<(DY@dKqFT$0><RE0w ztto?lTo?LNO%W=3l4faPvJzOQOyrXZcn5U}=G^n3hGvjOnAnBglQ^zoUv5w&Cp?^a zxzqRdIV~!H_XI1#dudf>bV}0JBkRS`){JD6)wD+T_0jJT3R7k9r1E68GH536_UA!{ zr0k4UT)$&?aXZwMh|$w5!<lbt|GT)@Q^{5`zIea|Kcwj3`cCK9SY7Vp>5CdD?cOcS ze0+M`FCVfYqS}y0s&@_Zcu#Y5njFRQtm8a!*YWa5V9n`k?O#8r7S?2T2}GffaYlp? zLgR0pkZS?MuH*+il?R-a)nE;qQv8m#yP<MGECBy<f=qZ02Pd_2isfDTin*&!TphgF zh4-FsI!+waMc}=~rrcR@0-T+7FBczCZtA{loY=R-Et4};%>H2Qh-+Q?CA>sdCk3h_ zQ+6&4{`9x`tTvi|SVs)?6%F~PfNKe8atvzXA3IkjAJyd-lpZg+v4(x39F!M+dQn;H z^VLk8sQ2gDS>3EF?Roq?M&Ks>SDBq*d@6F=_SCyaN<yHiK6~a|rk@)KBdQ6fYRdAo zR~cmv-w8jxp{WvK_n2$aFZPoG|7_sv-LAh}<$mT9fmX-X_}5*m=e_648k#Gpt^(9@ zt-!Vr6oUsBi=5wp?5*t8u|3IfN(~FP-~KNN=YqaXN6`4IosZQGT)+|H;?1qh1W3O| z8)?tb6NovZ%sJVBbLp|K=&#XrCWp{Jtp=kH7N7obpV{!V)tKz4=L`IO%|i2?@LfT1 z(qFDh3}<{V*Ah{+swf{@bkYi-%d`Z4>^m#!!}K#jdXMC}FPGzbjUNDFZpz(E))y3L zAlj3q?tZhtq(ue47Ckl+Ajwt>2?QB|KH7()QI_2czkV=z#5_swIYq{O%?Mt2p2YY~ zqS3Ms)ml-6cVtF;?%4th`}KP`3ocBsYqeF@XuNxL-t2xrYz_bzlcHj*rrgzs%+3u0 zHd#IZ2N(J`OHHM!9qL68Rr=-Vx^eZlE7u-u<oEqSpz768)r9^_a*J<ILK^GA_f0|7 zZWD|Nr?+I^+aDTyTBd9HS>b_h3;Iu{BXH9~G~3_SvA9Rq)@I2^AELTMQ26^yNlQ9= z4BHzKHy~VL!GTZ`eP^OwH5bMv5MoBK|9X)~l1$Xk8}D1#-5dHnAg;JjlO7juIS1si z=z7C^DU5WR3&HMAszb$_el%M2Sr0o~|9}@$v5-G)Z}w@VP><d{bG0p7#_I4+$C4^N zh7TLfRTkxzpN-?+J+Q_IR9??kexg2JAi1nGI`4PPb<sv&yoy_^@nVj(fYl#5x~rr* z0D8_Vt%cy!nPCd77(ScfOxgyvVV9dcl`tDrs)e0Y7YMQ>8%%4Qo4?yO#HH+4{4U}l zcV?PrGE=}po5$mAYX25TS^P;MLemW^^N6xzN<#E9=HOfYnYom@5B{pT^^TFX&7U5O z<?MN1_qjsQ8GYD_dd+r)UK+G0x1C^oEK~(x(y6&U8IjKR#a~;^NQJiJ$a97bSO~ep z(q9J_E5Y|GK(6LxAFDbZcP-4!J6PVjmlcEbMJ{nCm!4vsu-*#CkwSLxp_6xJ6gI*G zHrx!kWRTH$cF>o9<lJ!x{q|RA?mNXd^y*cl&xkOd79v|-Vl&sQIE;|JA}s*lK20>g zs937I*=V+P%2<Mb`EKEg93L!S3!86N%bWq#l4t8or1coTN&odVUOV6{OgFjz>c88^ z?B#-EJ8U5wW<js=FY%msx>EYkoI~UHJDZ$8jq6I#xCE0)M1OV%MN)>~4!~jnlm{q7 zsAgM5HOE4hh45@s+kxd`11@e_CG8kopV+jzJtdtJIKh-LA!$GPhAGXm{SRnbl0508 zNlnElYjEuPEd4xXY1i1xw5&N~=|M7YjsM5<2fS8(_mV9sk*?e(r;fLX;0QZ$cn;%l zcQVVL+Xm#!6Z2Ds6NIGZk$J!UW$wI9M=s*>AI^XlzS;90{R`tG&G>js9A2}X0X5DE zyxO2?54gDvuUK#`rR45k5&AKgXU$f*nk0|KzBE<)$CZOQo);_*rA(?+sNXxI7E%wC zm>_h#HxD)i1pa|rGuYX(D+=m^-w{D;!0ZE{HP$RR6O%$@c`^RaytV@;FySEUm5NKJ zJ^YY_qXGM*%D8H5*?eGVLu`6hHdOLTdmU>g{~pZ2;2w{L7f)&4Yo-ayNt62bsKIA{ zwQ!BSXOF%1bq_HN&r=2+osYP!lII<!tqO&B-b=+>-tr=ND+;YbTzmnQo@Yw<bfm&2 zx^UGlAR`i|^Bo$a;!Uuhyf}4KR+u!&V7(CoTEZJ;H7Czb!(+?m+AdP>377Vtvg1A+ zK-wm!gR?FzUSXT}URdh1yiR5jQ(n@Adg)AAT86MIpO~TL*uR@0dxbya2hci#!fB&s z<l?ExZ>7$Ql;9I#pY2`H(T+FxAcZE#nmXi|+t}$<=tbs|v2-(8&oLp14Y`)IR19Us zt$i7Jq;ZgYiKjNEvPpDfr(yfevx&IUD5aSNsW6km4J9l#XrP0pVZW*p8uPv!UUAue zJ*2D~^(_)dkMJmXzkT3r{P+YYEdeb3BCf@;!Y?My8494$si70)Wf9<X9}3VCe8P~` zSlw2*+e_XOEJVnT4Yx+GiJapy9JxzO=Ihw`#F$Y?ZMdh9&y9MSwD<P6t`&s+WQyT8 zs-MPdlbT6Z<;=Wv55$c{*#C_`G$|prX93rN5<%2S)6S(r=WMdLD=I?(zN~#Q99Rom zeA4>XE^=tQB|>jRy6G*X_so}KOt;wyTrsOUWV%^!v*UPjqU;aYntk8%)3k5@l0m$; z*=&6;$l!+1eII3>YOILvlbBcFXO;wsin!+)`sf|-2jNN#(=!V>9L`Q~+Z%srja<FY z%#=_(Y~XzQuqPv1`Pa}zMG==NA$wJ%UD+1D)-Q2<o0af$=U(JcMHU;G)g44tIrE`e z3*jW{&Fzi7VRn_rXwo}a4Y!TR$}Y;670);%`!CZpLGax2i*t)1p#qXT8B)f}p9IQ2 zR4W4<J7;49%|Sm#AQM><kK_mzJ-n>}+y0HY3cWBBu&SnbhcKjKqkK}DSy-?l4R$9U zu8?N@mY94w^IG`A=aQO|nN*o(kD-jX!n0V*Fw1oEK*x~i2ekzg=~A7tNjG)3+j64Y z0=^+y7*N?HX`N$XHu-jfxN~?o;PG-7gclmDtx~&Z5P0&c?r$eWZSQRqXT<wmOd6&} zbehO8Wk8G`0H?TwxH}{Fw0}oOqBJL2-@1;0=j3?PY7BMh`i+#1PCV8lJH4aw8=VPp za^fguGlVy=cB#GQEr1|%{(Q1JERmveTs;K?lJgWe3nGAT*4dZhHe|GVAQN0-8=rO- z)R*?ed0GNKjuus8O`eFd8kQK3xYga*R*$^!D$T|Z<f93;AB5Mxm?gZhc#;!X<=j;a z4L@bk7^$E!#uUR{{O5kY<Kyyj&Pq$W<=4^nfP-tc&0io>ynyU_gKFIy)t1rZDHEml znsWwTgaqo!2FlWM&OMmm_SpS?z-7=2!!IbGUrA~&`ubNy9A4ED$4U&1xzJ<cCnpcf zRnpV6-Ck?5<6H%r8gBE<o#&G`Z?%da@5EWomE3}(<#=Wcqu&6waAV6QRMBG;fN5A; z0ZSJ5Wop!AHptc~G`M@^50j$?#2^NKoz4aQ>Inpt^_YtFHM$=y%1i2)tBBnWX8k9f zIp<W;3@;dK*cB15U%yZ(6hH%+aP?B$H6M3UyP#Kp$+ISD0g|N2Ci{RyCTHBTCY8bt z$BBnR+mJ;25iNEWTJ@;P$iHSd_A1Tcl`&3l#pJFXo)ZSD<itzls%A67>Zq>km91c5 zq400+4^0Vx4bcLNT`^lI1S{K~maJWIN|O1CxkN{xKDbC^U1h_inh-~4GXOjNj93$q zAy^j-DN1tv^^=pwHze6LQ&a%nl+ctpeY<V@6c!rv+iu71MI@~P&(z)(5r(Ze(G%|y z=bO+vc7->Ms$!HbbL3yxVxGfuRaAwL5yd)Ze9c`z-*R!qg2Z8Bw_9C*yo!L`g}un3 ziF|dZIQ%ph$XQ!p;Gw?(&m=95c|giKUVkx-!9%#gdv1QCc*^~D@b;SLl@?Qx_I|!u zD36)&X6VPe3i-^<<pXr`-6*+V-FJvG)Vk$%nltCM2;=eL1IT!ZUYD5$%s7m+^}_;_ zOt;}QVIT@syMB?wlqw+_-pJ)a03eJsEr%IE1<uTSgLo?oZ3jE5AG=qgz>6;#3pdqN zwAInZTgumS-PyKdIb6>@dD=IH2*<Tj2+x@3pS0auS3X)XN7K}RR$)b{I=^Dnke%1~ z`R>e|J81_Nu3^Vd=W~*W3>D=8rLwkAI`yQ))Fg~#T^gZI&3xe>Iz+}9pZ0Ig1K}ir zs^+YKbEAirT3Jv=|5>WsU%T%<BIi!cFK;V=GnObD^hw_RTs_fzUhP+Fz!nkU22St5 z`T3%29?$Jj>)(!jSgbvnr{^!pc+SliXu`&i0bBXoZi~hfA3Wd?j?8O6fv8Dc_yiIu z>OmTR3`~jM{o;CqevdFG?nUu%;RKVgVCi#IYHjiZqslI~<LgPN8aG=m>)W4hx^=g@ z3b70DADuJb;txWZgC?DQW*<ZK>lihJP~p{BNzkp=Xgct~7~l?4HC;qD0TtrZP-sdx zoLE0rRx78t7*;*#DG<~f;qa1a^lRgoOEb69c7sv%=K22G*zg!9w~Ai=2Agod)$De5 z#w^lii;;G5=j$&j8+!AbnfzW`@$C1<l}LA5bbS=<qiNm6`na>NAF8pKkmn53^JMFz zfR30sFOC3QlGdkT!P-DY_mgN`4+!#lZv_NVn`gWWq6l_JCkm>RWJATo!-Bgu=f72B zi=LBcFBMU=?vX{zAT))cO8191J+*1iUC!RtPtS=vmb+5EvD${^qvBRHpILE-lcX&P zXcO|A90XHS!V-UF3%Ill)O(X@Vh^^4Z)L-rHC(~mIwII!m?}LO;JQ8ym>@kKl+O_( zY<w||PlW>C))0Fho(*;2%j4ho0vVH`SqL~cR{TjkR`}BBOXyT#lM<DC6xCZ^c>8#$ zGw=I}1kVyi4?XqiIH4{eGG&B1xn3C$jMoA#K5r76f-28<0H7sFOUZ&N-kn2Ru+Zla zprKJZK~QDq)T(GdIUWei8JGkv{9PBy+ho@h`7uQ`e+k&9m7S;exV)u1Hf(NcKP7)p zGjmcpYY|N&ef1MO&i3ul=g`?5nc6z{+((mUA+aX>qI<9hnNX=Le)6D0zy>F3)L=gx zuy;TulVXzK>Yt<lSY9Buf8dz}*W)CeC9&GC+fR^~*X7=P&W1|V#W>svH6vftUwuV2 zbNVpuZh33A-|U-giw6;3TIu$JS9oG+ijQA{;zk$!>fO5AloD5OS05Ps3fc3uem%Ik z!>$N@jlP|a<z3#B&t8G&N8yVD?A>#$3JMv4Hi5gKUw%NGWlcKHAuHq6S%nIb;b+OL zPMM3qmsE9S>ShMe0Ep=)%S<oJU0;bY=F0GSUem?zSzdLfjh;HjsJsdHSAj1CnUl+9 z>vtLCeuMzgE6}q~C#|wT5ul(KtVV2*(eKGUMYShk_?H10G5;hP&43UGTHJ(Rt;ZL* zxlhacpaJLCR;399?l=`A^`Oy`StlPfe*)?V6hE~___+<)s`Qqbyvo-4!r%32hkG2K z|LWm{Iw^`5#FzYUebN6P%N-v)V3kzSN^BtNtFe?J0Yp{ZGrb>x;Zl-BTU-ZKBg(WH zXu$67)pqw%3!s4nugp=(UE-CK<oV!)BUVjhtae6`2|U1;?#Ex+;t_tUOCfqp)NiYg z6dz-bQX(CPR_<+6)^kiI=mj~`qGoKYRX+M++?C=?Oosd8E$>lEM6~=y@S9inzg^!z zl*#fZg9M+d{&C3$VtqAZ>fg{v)nGJblt{M|BF^~iAru*kZ`c-D?Eq%~`;Mt5@@$RO zUR#Z5*~OI6ptwu!&mCa4DgXS&tl5Aqlqwu%@@CTZ+ni~)(7AJ%(h%#}IRoftz>Q>y zlP2rUGNDKjbHc-NBwc6+3MW%(6NS?Wgf7u{zJKvK4(o~E<^<~C3zMP?%}N3$hJb5B z9smlL0UQpRGwzaLp;ct<5i{FAB{{&?Vjq5Veg6)KMNl=<k@@6D^iIl<q%7z$0jr+l zW*)-01A6{Z7C*mqrZbXm1OxL}Rc(;U-l^MVGfY}{?1y=N1+6=VvSE5bY2v6LvdHid z!@B70EX5Xd(iM#25G4CaS!OmJA-5ecRMp%7;Bb2fGop8Nba9_bU1e?*p;nnFwJzru zZ=NJZy7nzBWQsYHmExTeHteT!@w|A3dp>xz-c9_R#*~m~ycg%9vaZxE7R>#)HEya4 zut8_e$xvON4w51{_F!|%oInUXsW9%(d;u-txOk!T(6J{YJ`K3HQWc+W-I4J=D(-{Q zoa?|zA`9AXcG{Q|?e+Ejr?iA@lxi`)Q>Tg3PDDsrhs*kT&-_nXNdA@Q1*iUn>kS$p zskCeL!wHKXk`7cMH!gh*gRewUYK};=lGlC->8!Rn&1<PhP%#9orM1Ay^O~o}S974s zD%>X+?+JsRJs`^R`*W$gMFf6;4Y?PGe1A5;o6`q(;~g+28){d*W=p@HP>JM}ffV`p z-42$^d7V$z%ry}nKF;$uCnL_4vAO~!vWfAVK-a<0lixSLoOv`#B|P{7nd`e>S@TK( z{B*MbIODy?txDI{ES3AY$%FJdmx?p)5<~wg14tFWk58ikl_L!`=RJf5AM@t2O~ya7 z=&+%ljs`24V|Mhgsy(*Uk_|`9O`MgbzxKKo0&(Smg9|~I?Vf@jqhrl+aW4P71`{G9 z*rld!lo0({rY*>T0v!p8U%yTu=&UzC<CbU5E^ygQ36m$v<`|=qxcDXjctz&wZ)qt3 z5$G~VqRe(D5p83$aaGu>pITCK$(&jtnu`@LxGQwY!=9C=x-5I`gIVN)?eyF@yzDAL z%G$N}2qjKiA=Upt*+1Kq;JG1NF?aH3pt{?ZZQ6=r)Kh3xx`g;?bRS;ELO4MZ^vX_S z)sXM{&TdZ`^vnSoH8p@O*{rhC7RWQXx!9c_L5gXDuN+o5T=C~5w-69oRjExG(x<8~ zYvrZ4-Is9<nyh_LU<I4S3U$pmtR{I|-z!K~_sknq>wgRcqQwaqO>5cn@|FruuSln0 zgBmQaGQ60UklSKVbY{}$)M=opqV|vP0*)jxDTuUug>6!9f<GfkJ45JJ-Mhbp2>;8A z!xG1d@yM3$M-}auG3HBB;>QV7$16Q92+vO3B+OM%iM`fSPcz1Ri>g#j*~;~O@;Dch z;zGmcgdO%LkdXsM-@Ao{tR&?=?ZD#UihOTE*jTd+?&)ebaI*aXY6kn_62i+hWPNLS z6yeJo<p)kSyi0-DgO^M<B4Y2uP-PhdGglm=$#htoVfRFG6j=yWuD5ml;Vz$1LH{bt zEJNE%m;GoLN(L~#vAK-*<JFKJsArgpjf*^AD8g+?pOZMc<UBEN)&se*;?9c6S`t}x z1GJcaS&xC6C$qUZp~BtH=KR)Z|LiLl_vM!yn|-tsPe;&=hqAmBO%JNCpUjD&U-AzN z!*EhtA<X7haVM$;b@$Wh;><zofkIv=DF$snP_CU;Ib6VvgXmEajY|;NHd3DH9@zmh z&=4+GBCyN72`*@%)qfY`Ud!GG-1*DCNU99mg9Gfr8-e#i=9rG`%?*3Yy%Hy9=WmHM zN#u<jI2#dE?$WXN9dukSPZulNJe{|~yD1MMu@#3=v=*Wg)*ROs4lcRI5(mxlYL2U0 zz(1$KaXf8JTH3VYTvn$}WdML4ioWdvgT=pi_G=WUR7z@(+aK_{#Om!+@w$i7l}Xz^ zpoPPq@$jvCdT@l%+nTc%^;Qp~Y*Jb)cXO1ThvlT6-DB8Ra-KqTI0A9*`(!R_h&5p* z8uF1gP))~_R#t`;ka1#v0J(+x23S8nG5}_sVqDm1jG;nlHNmLiEdaJoUGZCd1tJ%{ zKql32s4MGE#V&%2-B)G^k)^z8^YDA3x3xUJ<Go@p-ol6e?^Blij>^w+G4Q)ifT1UL z?eh==kTO;SHf<}OEou3W;dFQDKA;$x{Po`b)x4OVdj7fYW!3xX54>zR)1{cA6gUEQ zwjomDI9=L<(&<L(>+LU_bl*Uph#cRIoY)Wi8?JR%;1b%DFv3)sKxsOvDrnC^>e>=0 z?hd@Mfjv0+lN??+q#Inn?HC9V5CRB19?Tg1L&b|<iPcKbajdNT`Qe#ZLDYm43ofn1 zDk2G4u!^py4VL8hvB`sl0qrbgaG}nC$;gm2Z<%$rJ2Gb0nX~6tx{6f_@`B#)UDwmD z0D|{npF;6aSjqn;O?pmiXI;y2G!3s3^P^tNW%`18XIrVgszTKSG_r4n3O&(Ce65ja ze#|?5!n1ckp*7QM<FPQ^67P1UYf>o2#YNn>d0BCwoJ!P!zQ0(7vb3)lssU3|J-Smt zOZZ$k^v}rH-oxN5{UZ>}c`_csWR`ZvM8AsMFyZqxXD~i2lxX*XqJ4xz@IaVssdC!{ z`E%(_;Ls!DRI9yw6L?;p=;)4347gNxgw)=CVD-rauH}RGMm3k>^6HTHR81;you`mp z<(A6r4Z!HXzccddIsjd~3;OJKPSzvIM9t4aE5s8k_MYRT$Vhboh{uc6;a*DlF42cl z|5HRAH(Z*^ZqX~a<T|3|-NMP=GIG@3{d{kMa1{;;xlvRjFiMLwwa-IG1(l#OFM{B< zT`e}Xg&?-N;FMLNCJB(@{J1~@`!;t%8J{M?LSOs?*=9H$38GL64x7s+bF)>d{6MO| zRZeI9d=+I=l~AjMo@?G=+!aH7QB#p^k-jVe2#c0*Q*{sZ3N6mpY}yb!a^P-UQfSf- z99H|T|F8@x2|aIAA<Q2-!@C+6b_AGcFXy>xN0CbJ{V^kWyy~oSbB48v*24b)-2p4^ zT${Ri@4E}ni~4`QD0$nHCtL}%>@~Ky8&+iboc6^ZDSe73O#zU7;U&T52e(%ZdNfkx zda8AeIsni_qYc*tXSJ}O>eS(}P^fmt)=wOgzvW(eJX)rP^5iIpUyH$2I=DI#q??tY zzniFr%&R}R&qm=C5m?v@f~4;N4kxQ@ONF3N(2nnM<BM=SO{(XAFn4DcFr07gCl=7y zo10i;G3%78b$w?a??rYN2G*Q&a>pnR)+hEhsur`9DX<*I2JM(WQRvMS=odycC{(7C zjegomhM2VEi{YIMB7lmTuD0k3lY|ToxH%?ziCvOpH5A+iEJd~M66cM2>zAJvW<Wwu zSi#g363EsS`UdThjFoAq^sKV(Ql?Vn&2qwU`M$h4Z`RSV@@sT7c|VQ%q`XwN@)`N~ z&_~1Rh0c+O35QBrc^+;oXT7Vf0qJAs)D2wumu;CBFF$SSO-yLA!Lx>dM!o5@44U$Q zqPk&Hd*GXX<xypY-l*VD^Kj>5RAb7?t0nHYf4+|S-OYITC?x2m9m?TKk0#&(8J2t) z{lS9MEr%Ph;!J)&hv&TuC@U_$Fl~Rq|AS97DrnT0hcJU>)Jg2^XYA0%`nlW*!W&DK zu3Pa%h>j14r+Eua&vN|^GUvN}ObAtqVz4J@ZqN>mwX97fF7g2VZ4HnP{k~$*0YzRS ze)zP%7&+!tRH-t1PJK}D=&J)2;)5Pi3$)5^#uH-A5t2DVX<Y`ccUt=HSeH>_0AY_k zk38_ZJ<T_WihP7MWc^ui)xPR8!`aZoFeC>&bsl^2A`nP^iaQn*PKg0q$)r9bEEu2& zF>*`<P={arcNX#r!d?lJ7R*ex=a}CIKDazQz?@PU>t3Ha$8U-Cvm7xHyuA5^;bvYe z0N4220!*mCB5O+2eX%(u`7MjT3>E<xq6TNbIcCKhg3p{Lxt&rN)rtRih?Kj3G=V4^ z=q2J&UMmed$t-2|c4L*M#H$-0H=g|1xO+0CJ5qu6fYlOKKR=xoDItaxj?A<3(i^JG zXs>O`7K=nE_!C+3s_e#noHJ_bGMEk@r2GeWs%ozCHF5R>BRz7JG_h?mP$@qHCLhHs z7ai1Dnk7%*r=Nmb)FA6brRFF)QSgov5C|FmcPHiJyI^GqnVg-f1wEW%Gx_0-^nW=% z`&>z5>a2$quX8F62}xoA30087JGrzwvViWp=_|>;O5jBpD&+Elg-Fs{pupU}{mh14 z7+C)4TjRqf8~58jlK*bk`T0LGB_!}yvQd6{IRUw2f#>R`Ds)<b@3*JSi$EO$fjq0< zjFDNIM$et6?Vp*yhj`Elft2Y8X|#yLSF-s4XxaSshA6?=R(9Iqb)3=sg3b9zV%++1 zw-eoT==)1EZ+oJ$q2vCa)sWUrAC}Wz7$2S&e&ccCB{dIX%p%l-Qg%dE-(MY%(+B_M zH=6R+ooBq|K=_{b^RelX%>1Ct#(Xb1M>+1%tkQ>>kI9Dd1I+uy*tBP_hke8(s6G`e zAC_u`Bt_n8k!B;aD0utX;IimK-B7TbK6*?5-itLFkBGO-l5<tTzWxZR{MxG>+HS*( zlc#SDC8k$$1y*21Cb!YhJH(SOs@f}P<6ig6eE#XsDOR12a3%WRTdhQ$is-|6DXuUe zOH_AmjKMr~zQ~yP0{6@<K(Un#r_^Sg18R+z(8Gd=1E@B-iY=!=)9N@oI`Ez-C~oii z)4i&eES~ozkw!c1D&C*adK}N_xU-cHZVpSO=Xf0JmEq!`Fo0Y|z-cXe^gud2qi8$0 z=s&lJTjW*M|0vCG;6)+(yXlx8v#*O75W4E8<!7+E$hJ^svJ%iRQ{ZD%j;05P?k0B; zsowZBB1VrCd3?fnsqo((11llsIq3)Atg|uJEJ~9rSj{a%IX?z~9sOOyCIkxZ{4~Y> zHwvU3)13H`xTr+@Ms;=9aSvNpO-$V|2_QYNzGYpC8#rloTYCNhml75X1ij49(%uri zekinQUQtuNNx&wsH%MLpfm0^_C9rK$pGF4{MUpApD&iP^R{i8n#lNajVz<qUEk-Q^ z0pza{P^A=uYR`?st2P-kBj2xJ5=@Y(JM!HpdOaCHwE8a%4)<7~_5$295?^Uh1HeZa zQoI6S4S96T5K{EbgNmTUNZbu({$$s(OYj@Jb|2IV>&Q?%^eY(&YDj9P!U@BmBS4}L zZSl~c_gXcjYD5-{!0Mov9@U~lLLizM5yuHmfeB;X2gx`D!%etJo{F&UsTQrhnP||X z_sWm8rv;c4SolVvKXnyArJ!dFobQxBoxArPx7wLw&`Ta>(Kwcc*nedvC0#=gJ~Pn> z_=%Ev{Nh5SX}T4haVv{7ytMZzyFx}w!Bja&F--CN5G>TSKx}Lfth)=Inc8<^|IC6& zy=ItZzI!-qQ27{7$B~x%(#IXtvjr&arVpdv_0hLY3ECA%&8<~`jlR0E*Ck?b0M1=_ z2pFT_X4ypYDs80fB%Ap*=#o+@<uK#}nZ7agE4Np-_semh#|fa=V)Dx3Q$<CFfC&f> zs047|N`eU!Vyh<g$Zzr*u;Q@WECxr#Jq9rsin?L!*tPamamu<Ns))skAFa!eXk`dV z>qI2>;AYnlEfz=3L+<wrT|y(WGfgCP%H;$~f}5Sui6(GKc<5o)$gF!gj(ei`-;Vr$ zSBt&aD{I=+H&oW!x$M0YTjuo0EeieO-4)@r9fiE1UPTY5m7}}K#EiH2YvyNEh|iM; zXttrG&+c3MtRMZ556<pa6yqZ_gE5U<tAJV0<&>fZFlZ>W?$PnfKP;FFaKw{?=>uL+ z*z3Y*6c+{ZkLMLiC4*)P@4cb^nw*!>1uDIaIOAuE1|<ZuV>)2t28;k1PT2do3Lu1N zi*%?e2i(Dm49(zBectZ>UhVHa%dYCi?ES%&?YOsiRu?lgz*(tD8O{tYAtGU&?$Jzz zy6^zV&iWBdXrbc5+(cTao!LXe>Y5aS59mAbA=^92Ch$CC88ss2Zmf#HSTDD}0Kg5= zJP{{Bb__<h6IGGUY~ViU1UlXzA^PJ_bD}{i<z+)l&xFF@lRI=hVvuK_q!Tc#yrf9L zmja9(JN0qxM`&^GvGW)K<?#a%d%dmM|K)=C&%QB#*NCaG2sZP%H<G=OPeS)7IHIx~ zP!cEH6d^wB&vw9y%$NjZX;)!k91E^@+R8g1WAzNU_3f`KbhF6pF@U&pVBs;1CW?oM zKeU^IbS_vjmlo2bXY2axB$1SL+xpREg-i{Y#>b!J5{&em3mPo1($}5@H%0etW&L+p zSI=KgER@QR=fwQG7W$h)QeR++^L0zg$<TxA*0~CUPK$ez{*Py4N{DsZ)c0p72FO&; z^m>Ys=a0v=X$fM16^4pIv7<i}TcqOx@?xAC>|dSwHHqySP^Sj8<2=PknFVtmrr_$A z-)Ywnju~y%ncE=XhIegJ_{$K65K=}Y9(1oJ33r?TWtGAuMCEvDZXmTYZ>A8OrvYO? z!lDlU-0BO61oGydKv+kAWJ<2O%yncOpwd9#7a75Sk$ZFk^3TZmZB+^1#mI~&=LGyz zf_hE3hFi0`<-RPdoJGfBL)O@CLO`~+_(ylLJ%fNye{l=Cb4qMVdJl&A%0co2%#irh z6~o!=B7lVj)Bs8Tk2)qxZ6jplF~ai+sAik7{uE50`Sq@PDcNqY4l%(OPtPSldQcX+ zyd-Y?cz$_*R^*}(8rn_>uQbr)3_dxzDpU12zvikd@WLfo1o}4RA|zn$dlbbnl@j(F z(mc!k?GP@=KCp?=h6{ca1m+hjA?Du-yvs#t8v=HUsCc6EN83DhPxhiRDPwfs%qbY~ zQzIF#gIl{k%g`~P&}66G1ry{#$?CM5;ZIT7RSRyh6K9-7tkoYevdAeD*y9ewFZrG~ zhzYrP2CgpnaG@yYLf?ThEmcLrLSRT#&v_JiW;+2;rN5|-CFNd;r2<4hMZvk`i#et! zw<gzW@X5ku3fg)fs^^AO^f6{cKtt41l>+OIme}?_-u_eBQZZ4O^HjoX!XOe3(DDtB zGk;G|{QXO$PzsceMf+Ny0pdr|#iM@+c6s)dj9rkpr1j%*LT-1iqS$ugLLg8{4c?Z` zX@rL1^E!u$u7@zm^snB^14w}?<<&F7^bs6?+zMpxtd<wUr6x#1L<A?`+9B>s$s^YI z;v+#L{Dvx%B}BaPM=NFZvpW@s_~Mp|z7u7&Aie4TVE}q$^T|>%)w8kq5{iowXq?Ay zWD(`xT|2I5oe&`TQ$2Gg^Byh<?!`b_)b@6DKHz}T%;I)Jtc6L~`L!u`Hh>v2NZaG9 z2zW4xe0Wrl{`DKOQxUSvY>?n37SOv88V69lG3{5`5V}X!WtWB3fPA$Oyig4Pkg6ga zusuJ0X}t@WZl~$3Z$Q6J^vh7)0JqjpJZ=^goWy`o*{rtIVo|*gE*=TGdW@fai8xn< z7US~Tkzfh>7!y*adB<7*i=-WPUU<S`*|&@w3#nqDZmT6OTRHUf>;JscmD&R!)LIf@ z1LB+t>)X&!L@v|K(j%Oy5(q;`F@Zm>z>Q_EK*%n<ES?s*u9NLnA^?kb2t@eVR}6ss z8uk1it{+N9{iO;ZgA1p3pa3LH#t+rIJe@^{ni+E!Bc1>OR=QHTki73BGgC!L9<_BP z^rQo4(ZhSD4MeA++?dwwor2HO0`^@lL%>_$=nG;q2`zz4W^g<gsAgIIy%$4Dm&zEK zoQ0}sEX!v0o65LPF?&jizuNvy>Gclr%mTTsSS|JRhnJWE-cE@>7d&PkkA?1mg9Isr zwHkmfw9c~bj<>2pA&P{w%$f6CLl2P4c*ukl6CHWkC}$s)v|)fV%b5wCqh7B8J#uuv z!gS*8t5X^Q1`5|&N3m2^s?5^BTXjI(^IT{4g~2MKNy+D{FU5NXUeFBlCS`~Z_p}S3 zmm6IrH@z>yP(T;Oc~WRaDIO>)xm6SQPB}FGmK_3Hy_afn!CaTWp8>E{QT-Him*pyE zs+=nou;<<?SwE+U{fzn=iVsvR{G(CkmEBG#?s3OD8xI;hx!;>3*bxYH^lf8i@y|lz zw=ye)K(^lb7C3KHVDU(K5oErOyWY%H<9aRT+Eq`Vvf?{NgLRoh5%!GJugU+sVc*+s zaM}^yMT=cSR&^)=7k!~gao=fGUL2DbjXt-KoMUn7HFd>_PL5Tt8?3EQY0e1Ao5oz! zxmUHuRX{Fp5a;BXqVEHWH*WkxFMD8VXuBf{Ssnvka!zRnKySNjmUjCg1FAfjh1pDI z#kl|F=Rt#{HurD5*-39BT|lS$^MQxAS=0sYh*QeeZ+Kuu2jdulI`<ra=q(1I&3v9v zIIylSS+xq%xz$w%QnnSyQ~_Eeiu4FGTLOG40g-JaY+Yqwb)5g*XotL*75N3OE3F%j zH`3Fy{ey#Nll7@nWAUWAF)PpwihpzG$_|lmDip~|@E2&g4t8s`BXQ8-hbNq>&5#4& z=L6c3_O#S{Pojd{w|+l6z@D>w5DsmYf8=UOGyoBIt4a4AMalT_*cmXtvL0pgDNgif zgW?*<=br0CyGR)9kPjfh<^Nx%%0y1W^Iz}X3f>=$T_wg)3qZc^OZ*0*V#GFdeW#Lf zkk?KkcK~MChN?X{^nckc`O_ESk5n8-0Xozk-;t>}TRR6^g`QGHP&;%g4k+IRq`?kL zi%ku17y<iV$}AU`-J)Ppqkap;&E${Q#HP{!&tF-Nf=bEx0rJmQAIu7LOwQqYiYArn zDRM_!>p!ATX;_*o@XHDC>5r4q8+7^rBPi^HQ<`AyONSH_D%JdE{08o5K)*g5_=woC z$yyDUKK(~bX~3UO88Jd*7i96}dJ7Il043-fP_RL?tj=cmV}MFNzJGK|F1PSMvQY+A zDil03qly~=yyWH9b~j0N_H(N37vP?%*c?*W$w&mAW3G-vZT}-ajR^!1r5wqDsWfDt zkQOFt?blO$sZ@`@k}eaz`Cg289iTWR#hQ;0v1#P|csWG#$?E3C)H$`GvoX!J0=GW^ zW~=Dm)4F5Fm$&$VkMZ=W891$T_;ETU?17%gE|EWNj1}4x>W%;gUfZ(d+W?I_U?Miz z)o+jx@b6DR08Q!Dav}*Y!3|f@1ciS4NS2b64h$U0JEqmMjSS`N0yzIR;XNu9BpXEt zFs;b++FE9K@MJ7<K@g<IrrP#&Nf%h4=?>Bns4vA6V3b7=sR&b?l)zFf|HP4|2-bK4 z2bgIY^bss9dKC~mg_iNN)@_g}_zq#;=)jT!goB(I+@@6p*raQ5q_N;z#9(0V3l#g; z9D%7uqPLtZ_w#Bmx3*O<AVLF(qJ897FP=JE?i-vvl9~se?z5nvWK1tD0Rf!K9YZE+ zRbAdy?AtMCCQ%@SUT|=nG^hjI;lbGzvqa$y<fTlYjKc4|2x*Z-kpl>Md3J)NvZTEt z4N&_c_%NrCof{xlG8zH<lh-?eMEL#wmEi6rEDJ)Gbze)IQic`y&?*Q{n!J8vY66L( zMugzW@M1<_;6-V}qzc@hQ#v2e{DBb&{-p>IZ|d1FDPsZ~1eA7Je@12=J$S+^C<u^W zbNgUg2z8j2c`Oi5PT|INOTOn1x<>l|;Mt3MD=obf7)1I~vGG`yYEh6Cq_SLk@hZhK z5@ajzeir#+)D1O4;~?vq!%Yf+2$}y8Oq%4YQcQT;Mvb^fx`u!!YK8z~U^L;R{MLf2 zzy~!aK);e=_AZ{*>;;ojTa&K>SW(TKpi?N>>8*R@)B^U~VnjH=?Cd2iA_ovfpF|%s zg8a*(b*A|M@?5{<@>Vl|bJ}gb>IJ~J&glzM0mT<w1w`RDf4YJBza-K|pnW9*U8fFk zF8)4Rflgta8XT`OZi1dO9EhomBCQXr>|T8U2yZjWTScZYN*4bn?CAkpW(bThket7s z4otE^sT8|xfKmFY58_3X{GeK79s@+{<=9S)J8ntUrv@HYF)rGbNe=us$j1XfR6qXy z$DX|(+&poX?>)%C9~#d+b)qtbwQgWf1o~nY2?@4|-Jmx`9KclM^Y`mY&@B?DO#q#G zITgT?O$kEgz;7w4Mgk{1Bfqtq__1~4=LA{_;NymkS8tEQ`$XRXdZjHNM<~EqiDRE1 z@&RjRb=SfVCof?{aXuBqphkUg71nQzTo}eaYnb|k)rh*$gOM3dEC^;;ly{vdzBtO= z&3<~*q$$NwY=8~LVuwIg0n+aERr?o!tn?p`jjm&PVFuTxfC)18Uj<=7_(uSpqrfWA zYlSRAXL(8gN3YIkK!Z}DUxb%*JbOUse*~=B=h{|nEsW9;WD;w52nIgcn{SWAiDInr z2*Z0-sm23T#&bdgM56b9H0;rZD*Qemw=Wr+9+Oa;JHUCCD#ekaTDALHJ_T6=EQa?- zn{d*b%9COMKtEH4?F)<<M(N4F*j+U8g8H2gdD;3h3hDUalFo#<x)l)M&96ZFt{0OQ z#;|G)BA>WJ8BlwI-~W-c>gw?tW-UK;;(-?UC1QQ+226W_ykQ$j3B2WUZs%HZ&9LT^ zz)lDM7$&uR-l|Q-Vzei+9R1b>-2`NC{_+M`Y}%5eNP}1Sa)eZ=Qz<(j<Jj8Eu!_V$ zzJ`k*C~<*^WC+EwKvzGCXTUES`j2_(N$#70GtgnIf)^Da2tU!SRI+F}ZB!{UQ>+WH zv&4N|C1LqX&~dB`E$;6v;4QF048(u|g{0uGAl0{j0eXr7p3Az1nL38O-km^`#|urX zd|LkiiRDvqMjj*s?AE6_jW4geI1AVtBbdCd`nZ+_*}x2NIXFH{GDa!-uMHXYAB<F6 z5%N41gm4AHX;Dae>o%LHFL=+<UZK$E-FnE1%gE~e)ShkP0@XeQJUzBD-1}LT%33bc zY0rhGiBMIG)t8?0p$3-rivIhBI!@S<3V1&mgjb$TDqz10s?t`&PDKgri-2<_(W&s? zWV~c*k%?^)FGs8epSHkFxQURePjETlkk0-B1yL-<k)O(fR6M4;Vwj#GsRfACTi6$- zr^9-%(F)QOIEXIOmu^Z|8JHs8<TgL79!aqXq1ZQY+CY7j)#D$&#cv<*gX?X5>Ay$6 zdPwY`U^*1~b*J+Bu_MP_5b+JQ42W;%bDPQ~RWUWRfg#1}I>*fb)t-S7?9JnqC<K)J zlne#WQO6B|jbH5(D0xA)*Kz~>%)oeB#E_5Y(gGE~Vq09Xv$Lj8;~B*ZKAMBuuw?B5 zSABAHbyS@Z)qb7Nk9k0mGNOL4t!1%P45hBJN1jG}ciT~%lBe52>+0XTgat`}PkB&~ zVP<kqu1QG(%B*v1edD^<D%;yk^#^Wbbqgc?_|}hst~`P$@50!I(oMJ-<CG%hz7}^t z@2n9dQ~@aYT)ZNF&W`K<D0=DB<O}L2lB9+#UJ6g1r?{54An^bq_EY&oYhwUiZ)(fu z#JBYYJvPB~RD4tK{Hxrm?II)S+v|>n;iNNTvTJ5Q>1qQ5t}#uuRsT)*IE2EtPr&|6 zIlClbtWo)aAe}kketuo-Ye&J7&N#Mep<5MoLz_NfMe#k6poa1VPotCC@}sqDu7w7Y z2~|JkW+Zu^IB&W32b@RA{XMK39qH!TbPfE1ra4;#igoKkFyR2J{VLnyHlVqv4+Q?R ol$apEeE$D`{~u|DkWNYDq4iIV>RpQ!K)^p;&Bq$$YS!=m59%a}G5`Po diff --git a/rust/limux-host-linux/src/app_config.rs b/rust/limux-host-linux/src/app_config.rs index 26894102..e319d386 100644 --- a/rust/limux-host-linux/src/app_config.rs +++ b/rust/limux-host-linux/src/app_config.rs @@ -1,7 +1,11 @@ +use std::collections::BTreeMap; use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; +const MIN_FONT_SIZE: f32 = 8.0; +const MAX_FONT_SIZE: f32 = 255.0; + use serde::Deserialize; use serde_json::{json, Value}; @@ -46,6 +50,8 @@ pub struct AppConfig { pub notifications: NotificationConfig, #[serde(skip)] pub font_size: Option<f32>, + #[serde(skip)] + pub ui_font_sizes: BTreeMap<String, f32>, } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -254,7 +260,24 @@ fn parse_app_config_value(root: &Value) -> AppConfig { .get("font_size") .and_then(Value::as_f64) .map(|v| v as f32) - .filter(|v| (1.0..=255.0).contains(v)); + .filter(|v| (MIN_FONT_SIZE..=MAX_FONT_SIZE).contains(v)); + + let ui_font_sizes = root + .get("ui_font_sizes") + .and_then(Value::as_object) + .map(|sizes| { + sizes + .iter() + .filter_map(|(key, value)| { + value + .as_f64() + .map(|v| v as f32) + .filter(|v| (MIN_FONT_SIZE..=MAX_FONT_SIZE).contains(v)) + .map(|v| (key.clone(), v)) + }) + .collect() + }) + .unwrap_or_default(); AppConfig { focus: FocusConfig { @@ -269,6 +292,7 @@ fn parse_app_config_value(root: &Value) -> AppConfig { sound: notification_sound, }, font_size, + ui_font_sizes, } } @@ -309,6 +333,12 @@ fn save_to_path(path: &Path, config: &AppConfig) -> Result<(), String> { root.remove("font_size"); } + if config.ui_font_sizes.is_empty() { + root.remove("ui_font_sizes"); + } else { + root.insert("ui_font_sizes".to_string(), json!(config.ui_font_sizes)); + } + let serialized = serde_json::to_string_pretty(&Value::Object(root)).expect("config should serialize"); write_config_root_atomically(path, &serialized) @@ -567,6 +597,25 @@ mod tests { assert_eq!(loaded.config.font_size, Some(18.5)); } + #[test] + fn load_from_path_rejects_too_small_font_sizes() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write( + &path, + r#"{ + "font_size": 7.5 +} +"#, + ) + .expect("write config"); + + let loaded = load_from_path(&path); + + assert_eq!(loaded.config.font_size, None); + } + #[test] fn load_from_path_reads_notification_preferences() { let dir = TempDir::new().expect("temp dir"); @@ -647,6 +696,59 @@ mod tests { ); } + #[test] + fn load_from_path_reads_ui_font_sizes_when_valid() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + fs::write( + &path, + r#"{ + "ui_font_sizes": { + "sidebar_workspace_name": 16.5, + "too_small": 7.5, + "invalid": 999 + } +}"#, + ) + .expect("write config"); + + let loaded = load_from_path(&path); + assert_eq!( + loaded.config.ui_font_sizes.get("sidebar_workspace_name"), + Some(&16.5) + ); + assert!(!loaded.config.ui_font_sizes.contains_key("too_small")); + assert!(!loaded.config.ui_font_sizes.contains_key("invalid")); + } + + #[test] + fn save_to_path_writes_and_clears_ui_font_sizes() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + fs::create_dir_all(path.parent().expect("config dir")).expect("create config dir"); + + let mut config = AppConfig::default(); + config + .ui_font_sizes + .insert("sidebar_workspace_name".to_string(), 16.5); + save_to_path(&path, &config).expect("save ui font sizes"); + + let raw = fs::read_to_string(&path).expect("read config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse config"); + assert_eq!( + parsed["ui_font_sizes"]["sidebar_workspace_name"], + json!(16.5) + ); + + config.ui_font_sizes.clear(); + save_to_path(&path, &config).expect("clear ui font sizes"); + + let raw = fs::read_to_string(&path).expect("read cleared config"); + let parsed: Value = serde_json::from_str(&raw).expect("parse cleared config"); + assert!(parsed.get("ui_font_sizes").is_none()); + } + #[test] fn save_to_path_writes_and_clears_font_size() { let dir = TempDir::new().expect("temp dir"); diff --git a/rust/limux-host-linux/src/control_bridge.rs b/rust/limux-host-linux/src/control_bridge.rs index b0f58bde..4b6e357a 100644 --- a/rust/limux-host-linux/src/control_bridge.rs +++ b/rust/limux-host-linux/src/control_bridge.rs @@ -172,6 +172,8 @@ pub enum ControlCommand { /// the currently-active workspace is used. CreateNotification { target: WorkspaceTarget, + surface_hint: Option<String>, + kind: Option<String>, title: String, subtitle: String, body: String, @@ -655,6 +657,7 @@ fn handle_method( }; let subtitle = optional_string(params, &["subtitle"]).unwrap_or_default(); let body = optional_string(params, &["body", "message"]).unwrap_or_default(); + let kind = optional_string(params, &["kind", "status", "level"]); // allow_name = true: lets agent hooks target a peer by name. let target = match parse_optional_workspace_target(params, true) { Ok(target) => target, @@ -664,6 +667,11 @@ fn handle_method( ( ControlCommand::CreateNotification { target, + surface_hint: optional_string( + params, + &["surface_id", "surface_ref", "surface"], + ), + kind, title, subtitle, body, @@ -974,6 +982,35 @@ mod tests { ); } + #[test] + fn notification_route_preserves_kind_and_surface_hint() { + let response = dispatch_request( + r#"{"id":1,"method":"notification.create","params":{"workspace_id":"codex","surface_id":"surface:4:tab-a","kind":"finished","title":"Process needs attention","body":"Codex finished"}}"#, + &|command| match command { + ControlCommand::CreateNotification { + target, + surface_hint, + kind, + title, + body, + reply, + .. + } => { + assert_eq!(target, WorkspaceTarget::Name("codex".to_string())); + assert_eq!(surface_hint.as_deref(), Some("surface:4:tab-a")); + assert_eq!(kind.as_deref(), Some("finished")); + assert_eq!(title, "Process needs attention"); + assert_eq!(body, "Codex finished"); + let _ = reply.send(Ok(json!({ "ok": true }))); + } + other => panic!("unexpected command: {other:?}"), + }, + ); + + assert_eq!(response.error, None); + assert!(response.result.is_some()); + } + #[test] fn surface_health_route_accepts_surface_refs() { let response = dispatch_request( diff --git a/rust/limux-host-linux/src/layout_state.rs b/rust/limux-host-linux/src/layout_state.rs index a234b7fd..0ac657e9 100644 --- a/rust/limux-host-linux/src/layout_state.rs +++ b/rust/limux-host-linux/src/layout_state.rs @@ -114,6 +114,7 @@ pub enum RestorableAgentKind { Codex, OpenCode, Gemini, + Pi, } impl RestorableAgentKind { @@ -132,6 +133,7 @@ impl RestorableAgentKind { Self::Codex => "codex", Self::OpenCode => "opencode", Self::Gemini => "gemini", + Self::Pi => "pi", } } @@ -141,6 +143,7 @@ impl RestorableAgentKind { Self::Codex => "codex", Self::OpenCode => "opencode", Self::Gemini => "gemini", + Self::Pi => "pi", } } } @@ -493,6 +496,7 @@ impl RestorableAgentIndex { (RestorableAgentKind::Codex, "codex-hook-sessions.json"), (RestorableAgentKind::OpenCode, "opencode-hook-sessions.json"), (RestorableAgentKind::Gemini, "gemini-hook-sessions.json"), + (RestorableAgentKind::Pi, "pi-hook-sessions.json"), ] { let path = dir.join(file_name); let Ok(raw) = fs::read_to_string(&path) else { @@ -720,6 +724,11 @@ fn build_resume_command( parts.push(session_id.clone()); parts.extend(preserved_tail); } + RestorableAgentKind::Pi => { + parts.push("--session".to_string()); + parts.push(session_id.clone()); + parts.extend(preserved_tail); + } } let command = parts @@ -843,6 +852,12 @@ fn is_resume_selector(kind: RestorableAgentKind, arg: &str) -> bool { RestorableAgentKind::Claude | RestorableAgentKind::Gemini => { arg == "--resume" || arg.starts_with("--resume=") || arg == "--continue" } + RestorableAgentKind::Pi => { + arg == "--session" + || arg.starts_with("--session=") + || arg == "--continue" + || arg == "-c" + } } } diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 00f0753f..4f17fa68 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -109,6 +109,35 @@ pub enum PaneEmptyReason { MovedLastTabOut, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PaneNotificationKind { + Attention, + Finished, +} + +impl PaneNotificationKind { + fn tab_css_class(self) -> &'static str { + match self { + Self::Attention => "limux-tab-notify-attention", + Self::Finished => "limux-tab-notify-finished", + } + } + + fn pane_css_class(self) -> &'static str { + match self { + Self::Attention => "limux-pane-notify-attention", + Self::Finished => "limux-pane-notify-finished", + } + } + + fn tab_status_css_class(self) -> &'static str { + match self { + Self::Attention => "limux-tab-status-attention", + Self::Finished => "limux-tab-status-finished", + } + } +} + const HOST_ENTRY_CSS_CLASS: &str = "limux-host-entry"; const TAB_RENAME_ENTRY_CSS_CLASS: &str = "limux-tab-rename-entry"; const TAB_RENAME_ENTRY_CSS_CLASSES: [&str; 2] = [HOST_ENTRY_CSS_CLASS, TAB_RENAME_ENTRY_CSS_CLASS]; @@ -324,6 +353,29 @@ pub const PANE_CSS: &str = r#" color: @window_fg_color; background: alpha(@window_fg_color, 0.08); } +.limux-tab-unread { + color: @window_fg_color; + font-weight: 600; +} +.limux-tab-status { + font-size: 10px; + min-width: 10px; + margin-right: 3px; +} +.limux-tab-status-attention { + color: @accent_color; +} +.limux-tab-status-finished { + color: rgb(46, 194, 126); +} +.limux-tab-notify-attention { + background: alpha(@accent_bg_color, 0.16); + box-shadow: inset 0 -2px 0 0 @accent_bg_color; +} +.limux-tab-notify-finished { + background: rgba(46, 194, 126, 0.14); + box-shadow: inset 0 -2px 0 0 rgb(46, 194, 126); +} .limux-tab-close { background: none; border: none; @@ -418,6 +470,12 @@ pub const PANE_CSS: &str = r#" .limux-drop-preview-center { background: alpha(@accent_bg_color, 0.14); } +.limux-pane-notify-attention { + box-shadow: inset 0 0 0 2px @accent_bg_color; +} +.limux-pane-notify-finished { + box-shadow: inset 0 0 0 2px rgb(46, 194, 126); +} "#; // --------------------------------------------------------------------------- @@ -721,6 +779,27 @@ pub fn activate_tab_in_pane(pane_widget: >k::Widget, tab_id: &str) -> bool { true } +pub fn mark_tab_notification( + pane_widget: >k::Widget, + tab_id: &str, + kind: PaneNotificationKind, +) -> bool { + let Some(internals) = find_pane_internals(pane_widget) else { + return false; + }; + + let tab_state = internals.tab_state.borrow(); + let Some(entry) = tab_state.tabs.iter().find(|entry| entry.id == tab_id) else { + return false; + }; + clear_tab_notification_classes(&entry.tab_button); + entry.tab_button.add_css_class("limux-tab-unread"); + entry.tab_button.add_css_class(kind.tab_css_class()); + mark_tab_status_icon(&entry.tab_button, kind); + refresh_pane_notification_ring_for_tabs(&internals.pane_outer, &tab_state.tabs); + true +} + fn normalize_surface_hint(raw: &str) -> &str { raw.trim() .strip_prefix("surface:") @@ -1917,6 +1996,13 @@ fn build_tab_button_from_label( pin_icon.set_visible(false); pin_icon.set_can_target(false); + let status_icon = gtk::Label::builder() + .label("\u{25CF}") + .visible(false) + .build(); + status_icon.add_css_class("limux-tab-status"); + status_icon.set_can_target(false); + let close_btn = gtk::Button::builder() .icon_name("window-close-symbolic") .has_frame(false) @@ -1926,6 +2012,7 @@ fn build_tab_button_from_label( let inner_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); inner_box.set_can_target(false); inner_box.append(&pin_icon); + inner_box.append(&status_icon); inner_box.append(label); let tab_btn = gtk::Box::new(gtk::Orientation::Horizontal, 0); @@ -1941,7 +2028,11 @@ fn build_tab_button_from_label( let content_stack = internals.content_stack.clone(); let tab_state = internals.tab_state.clone(); let callbacks = internals.callbacks.clone(); + let tab_button = tab_btn.clone(); click.connect_pressed(move |_, _, _, _| { + if tab_has_rename_entry(&tab_button) { + return; + } activate_tab(&tab_strip, &content_stack, &tab_state, &tab_id); (callbacks.on_state_changed)(); }); @@ -2133,6 +2224,49 @@ fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextM menu.popup(); } +fn tab_has_rename_entry(tab_button: >k::Box) -> bool { + fn widget_has_rename_entry(widget: >k::Widget) -> bool { + if widget + .downcast_ref::<gtk::Entry>() + .is_some_and(|entry| entry.has_css_class(TAB_RENAME_ENTRY_CSS_CLASS)) + { + return true; + } + + let mut child = widget.first_child(); + while let Some(current) = child { + if widget_has_rename_entry(¤t) { + return true; + } + child = current.next_sibling(); + } + + false + } + + widget_has_rename_entry(&tab_button.clone().upcast()) +} + +fn focus_tab_rename_entry(entry: >k::Entry, focus_settled: Rc<Cell<bool>>) { + focus_settled.set(false); + entry.grab_focus(); + entry.select_region(0, -1); + + let entry = entry.clone(); + glib::idle_add_local_once(move || { + entry.grab_focus(); + entry.select_region(0, -1); + + let entry = entry.clone(); + let focus_settled = focus_settled.clone(); + glib::idle_add_local_once(move || { + entry.grab_focus(); + entry.select_region(0, -1); + focus_settled.set(true); + }); + }); +} + fn show_rename_dialog( label: >k::Label, tab_state: &Rc<RefCell<TabState>>, @@ -2151,15 +2285,20 @@ fn show_rename_dialog( .text(¤t_name) .width_chars(15) .build(); + entry.set_focusable(true); + entry.set_can_focus(true); for css_class in TAB_RENAME_ENTRY_CSS_CLASSES { entry.add_css_class(css_class); } + let parent_can_target = parent.can_target(); + parent.set_can_target(true); label.set_visible(false); // Insert entry before the close button parent.insert_child_after(&entry, Some(label)); - entry.grab_focus(); - entry.select_region(0, -1); + let focus_settled = Rc::new(Cell::new(false)); + focus_tab_rename_entry(&entry, focus_settled.clone()); + let hover_focus_guard = Rc::new(RefCell::new(Some(terminal::inhibit_hover_terminal_focus()))); // On activate (Enter) or focus-out, commit rename let lbl = label.clone(); @@ -2176,6 +2315,7 @@ fn show_rename_dialog( let tid = tid.clone(); let parent = parent_for_cleanup.clone(); let callbacks = callbacks.clone(); + let hover_focus_guard = hover_focus_guard.clone(); move |entry: >k::Entry| { if commit.get() { return; @@ -2191,6 +2331,8 @@ fn show_rename_dialog( } lbl.set_visible(true); parent.remove(entry); + parent.set_can_target(parent_can_target); + hover_focus_guard.borrow_mut().take(); (callbacks.on_state_changed)(); } }; @@ -2203,10 +2345,24 @@ fn show_rename_dialog( } { let do_rename = do_rename.clone(); + let focus_settled = focus_settled.clone(); + let commit = commit.clone(); let focus_controller = gtk::EventControllerFocus::new(); focus_controller.connect_leave(move |ctrl| { if let Some(widget) = ctrl.widget() { if let Some(entry) = widget.downcast_ref::<gtk::Entry>() { + if !focus_settled.get() && !commit.get() { + let entry = entry.clone(); + let focus_settled = focus_settled.clone(); + let commit = commit.clone(); + glib::idle_add_local_once(move || { + if !focus_settled.get() && !commit.get() { + entry.grab_focus(); + entry.select_region(0, -1); + } + }); + return; + } do_rename(entry); } } @@ -2386,6 +2542,79 @@ fn rebuild_tab_strip(tab_strip: >k::Box, tab_state: &Rc<RefCell<TabState>>) { } } +fn clear_tab_notification_classes(tab_button: >k::Box) { + tab_button.remove_css_class("limux-tab-unread"); + tab_button.remove_css_class(PaneNotificationKind::Attention.tab_css_class()); + tab_button.remove_css_class(PaneNotificationKind::Finished.tab_css_class()); + if let Some(status) = tab_status_label(tab_button) { + status.remove_css_class(PaneNotificationKind::Attention.tab_status_css_class()); + status.remove_css_class(PaneNotificationKind::Finished.tab_status_css_class()); + status.set_visible(false); + } +} + +fn mark_tab_status_icon(tab_button: >k::Box, kind: PaneNotificationKind) { + if let Some(status) = tab_status_label(tab_button) { + status.remove_css_class(PaneNotificationKind::Attention.tab_status_css_class()); + status.remove_css_class(PaneNotificationKind::Finished.tab_status_css_class()); + status.add_css_class(kind.tab_status_css_class()); + status.set_visible(true); + } +} + +fn tab_status_label(tab_button: >k::Box) -> Option<gtk::Label> { + tab_button + .first_child() + .and_then(|child| child.downcast::<gtk::Box>().ok()) + .and_then(|inner_box| { + let mut child = inner_box.first_child(); + while let Some(widget) = child { + if let Some(label) = widget.downcast_ref::<gtk::Label>() { + if label.has_css_class("limux-tab-status") { + return Some(label.clone()); + } + } + child = widget.next_sibling(); + } + None + }) +} + +fn clear_pane_notification_ring(pane_outer: >k::Box) { + pane_outer.remove_css_class(PaneNotificationKind::Attention.pane_css_class()); + pane_outer.remove_css_class(PaneNotificationKind::Finished.pane_css_class()); +} + +fn pane_outer_for_content_stack(content_stack: >k::Stack) -> Option<gtk::Box> { + content_stack + .parent() + .and_then(|overlay| overlay.parent()) + .and_then(|outer| outer.downcast::<gtk::Box>().ok()) +} + +fn refresh_pane_notification_ring_for_tabs(pane_outer: >k::Box, tabs: &[TabEntry]) { + clear_pane_notification_ring(pane_outer); + let kind = if tabs.iter().any(|entry| { + entry + .tab_button + .has_css_class(PaneNotificationKind::Attention.tab_css_class()) + }) { + Some(PaneNotificationKind::Attention) + } else if tabs.iter().any(|entry| { + entry + .tab_button + .has_css_class(PaneNotificationKind::Finished.tab_css_class()) + }) { + Some(PaneNotificationKind::Finished) + } else { + None + }; + + if let Some(kind) = kind { + pane_outer.add_css_class(kind.pane_css_class()); + } +} + fn rebind_moved_tab_entry(entry: &mut TabEntry, target: &Rc<PaneInternals>) { if let TabKind::Terminal { state } = &entry.kind { state.handle.replace_callbacks(make_terminal_callbacks( @@ -2706,10 +2935,14 @@ fn activate_tab( for entry in &ts.tabs { if entry.id == tab_id { entry.tab_button.add_css_class("limux-tab-active"); + clear_tab_notification_classes(&entry.tab_button); } else { entry.tab_button.remove_css_class("limux-tab-active"); } } + if let Some(pane_outer) = pane_outer_for_content_stack(content_stack) { + refresh_pane_notification_ring_for_tabs(&pane_outer, &ts.tabs); + } if content_stack.child_by_name(tab_id).is_some() { content_stack.set_visible_child_name(tab_id); @@ -2749,6 +2982,9 @@ fn remove_tab( tab_strip.remove(&entry.tab_button); content_stack.remove(&entry.content); + if let Some(pane_outer) = pane_outer_for_content_stack(content_stack) { + refresh_pane_notification_ring_for_tabs(&pane_outer, &ts.tabs); + } if ts.tabs.is_empty() { drop(ts); @@ -3482,6 +3718,18 @@ mod tests { assert!(!PANE_CSS.contains("border: 1px solid rgba(0, 145, 255, 0.5);")); } + #[test] + fn pane_css_includes_attention_and_finished_notification_rings() { + assert!(PANE_CSS.contains(".limux-tab-status")); + assert!(PANE_CSS.contains(".limux-tab-status-attention")); + assert!(PANE_CSS.contains(".limux-tab-status-finished")); + assert!(PANE_CSS.contains(".limux-tab-notify-attention")); + assert!(PANE_CSS.contains(".limux-tab-notify-finished")); + assert!(PANE_CSS.contains(".limux-pane-notify-attention")); + assert!(PANE_CSS.contains(".limux-pane-notify-finished")); + assert!(PANE_CSS.contains("inset 0 0 0 2px")); + } + #[test] fn pane_entries_use_shared_host_entry_class() { assert_eq!( diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs index 225fe1ca..ecc14d44 100644 --- a/rust/limux-host-linux/src/settings_editor.rs +++ b/rust/limux-host-linux/src/settings_editor.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use adw::prelude::*; @@ -16,6 +16,238 @@ pub const SETTINGS_CSS: &str = r#" } "#; +const MIN_FONT_SIZE: f64 = 8.0; +const MAX_FONT_SIZE: f64 = 255.0; +const FONT_SIZE_STEP: f64 = 1.0; + +#[derive(Clone, Copy, Debug)] +pub struct UiFontDescriptor { + pub id: &'static str, + pub label: &'static str, + pub subtitle: &'static str, + pub selector: &'static str, + pub default_size: f32, +} + +impl UiFontDescriptor { + fn css_property(self) -> &'static str { + match self.id { + "pane_action_icon" | "pane_tab_close_icon" => "-gtk-icon-size", + _ => "font-size", + } + } +} + +pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ + UiFontDescriptor { + id: "sidebar_workspace_name", + label: "Sidebar workspace name", + subtitle: "Workspace names in the left sidebar", + selector: ".limux-ws-name", + default_size: 15.0, + }, + UiFontDescriptor { + id: "sidebar_favorite_star", + label: "Sidebar favorite star", + subtitle: "Pinned workspace star icon in sidebar rows", + selector: ".limux-ws-star-btn", + default_size: 22.0, + }, + UiFontDescriptor { + id: "sidebar_notification_dot", + label: "Sidebar notification dot", + subtitle: "Unread notification marker in workspace rows", + selector: ".limux-notify-dot, .limux-notify-dot-hidden", + default_size: 10.0, + }, + UiFontDescriptor { + id: "sidebar_notification_message", + label: "Sidebar notification message", + subtitle: "Notification preview text below workspace names", + selector: ".limux-notify-msg, .limux-notify-msg-unread", + default_size: 11.0, + }, + UiFontDescriptor { + id: "sidebar_section_title", + label: "Sidebar section title", + subtitle: "The WORKSPACES heading above the sidebar list", + selector: ".limux-sidebar-title", + default_size: 11.0, + }, + UiFontDescriptor { + id: "sidebar_workspace_path", + label: "Sidebar workspace path", + subtitle: "Folder path text below workspace names", + selector: ".limux-ws-path", + default_size: 12.0, + }, + UiFontDescriptor { + id: "sidebar_git_branch", + label: "Sidebar git branch", + subtitle: "Git branch pill in workspace rows", + selector: ".limux-ws-branch", + default_size: 11.0, + }, + UiFontDescriptor { + id: "sidebar_ports", + label: "Sidebar ports", + subtitle: "Localhost port pill in workspace rows", + selector: ".limux-ws-ports", + default_size: 11.0, + }, + UiFontDescriptor { + id: "pane_tab_title", + label: "Pane tab title", + subtitle: "Terminal and browser tab labels in pane headers", + selector: ".limux-tab", + default_size: 12.0, + }, + UiFontDescriptor { + id: "pane_tab_status_icon", + label: "Pane tab status icon", + subtitle: "Attention and finished marker shown inside pane tabs", + selector: ".limux-tab-status", + default_size: 10.0, + }, + UiFontDescriptor { + id: "pane_pin_icon", + label: "Pane pinned-tab icon", + subtitle: "Pin indicator shown inside pane tabs", + selector: ".limux-pin-icon", + default_size: 9.0, + }, + UiFontDescriptor { + id: "pane_tab_rename_entry", + label: "Pane tab rename entry", + subtitle: "Inline text field used while renaming a tab", + selector: ".limux-tab-rename-entry", + default_size: 12.0, + }, + UiFontDescriptor { + id: "pane_action_icon", + label: "Pane action icons", + subtitle: "New tab, split, settings, close, and browser navigation icons in pane headers", + selector: ".limux-pane-action image", + default_size: 16.0, + }, + UiFontDescriptor { + id: "pane_tab_close_icon", + label: "Pane tab close icon", + subtitle: "Close button icon inside each pane tab", + selector: ".limux-tab-close image", + default_size: 12.0, + }, + UiFontDescriptor { + id: "notification_panel_title", + label: "Notification panel title", + subtitle: "Header text in the notification panel", + selector: ".limux-notification-panel-title", + default_size: 12.0, + }, + UiFontDescriptor { + id: "notification_panel_empty", + label: "Notification empty state", + subtitle: "Empty-state text in the notification panel", + selector: ".limux-notification-empty", + default_size: 12.0, + }, + UiFontDescriptor { + id: "notification_panel_status", + label: "Notification status dot", + subtitle: "Attention and finished status marker in notification rows", + selector: ".limux-notification-status", + default_size: 10.0, + }, + UiFontDescriptor { + id: "notification_panel_workspace", + label: "Notification workspace", + subtitle: "Workspace label in notification rows", + selector: ".limux-notification-workspace", + default_size: 11.0, + }, + UiFontDescriptor { + id: "notification_panel_message", + label: "Notification message", + subtitle: "Primary message text in notification rows", + selector: ".limux-notification-message", + default_size: 12.0, + }, + UiFontDescriptor { + id: "notification_panel_detail", + label: "Notification detail", + subtitle: "Secondary detail text in notification rows", + selector: ".limux-notification-detail", + default_size: 11.0, + }, + UiFontDescriptor { + id: "browser_url_entry", + label: "Browser URL entry", + subtitle: "Address field in browser panes", + selector: ".limux-browser-url-entry", + default_size: 12.0, + }, + UiFontDescriptor { + id: "browser_search_entry", + label: "Browser search entry", + subtitle: "Find-in-page entry in browser panes", + selector: ".limux-browser-search-entry", + default_size: 12.0, + }, + UiFontDescriptor { + id: "keybind_hint", + label: "Keybinding editor hint", + subtitle: "Explanatory hint text in the keybinding editor", + selector: ".limux-keybind-hint", + default_size: 12.0, + }, + UiFontDescriptor { + id: "keybind_default", + label: "Keybinding default label", + subtitle: "Default binding text in keybinding rows", + selector: ".limux-keybind-default", + default_size: 12.0, + }, + UiFontDescriptor { + id: "keybind_error", + label: "Keybinding error text", + subtitle: "Validation error text in the keybinding editor", + selector: ".limux-keybind-error", + default_size: 12.0, + }, + UiFontDescriptor { + id: "keybind_row_hint", + label: "Keybinding row hint", + subtitle: "Per-row helper text in the keybinding editor", + selector: ".limux-keybind-row-hint", + default_size: 12.0, + }, + UiFontDescriptor { + id: "toast", + label: "Toast message", + subtitle: "Small in-terminal Limux toast notifications", + selector: ".limux-toast", + default_size: 12.0, + }, +]; + +pub fn ui_font_sizes_css(config: &AppConfig) -> String { + let mut css = String::new(); + for descriptor in UI_FONT_DESCRIPTORS { + let size = config + .ui_font_sizes + .get(descriptor.id) + .copied() + .unwrap_or(descriptor.default_size) + .clamp(MIN_FONT_SIZE as f32, MAX_FONT_SIZE as f32); + css.push_str(&format!( + "{} {{ {}: {size}px; }}\n", + descriptor.selector, + descriptor.css_property() + )); + } + css +} + type OnConfigChanged = dyn Fn(&AppConfig, &AppConfig); pub struct SettingsEditorInput { @@ -72,6 +304,10 @@ fn build_settings_window_content(window: &adw::Window, input: SettingsEditorInpu let general_stack_page = stack.add_titled(&general_page, Some("general"), "General"); general_stack_page.set_icon_name(Some("preferences-system-symbolic")); + let fonts_page = build_fonts_page(&input); + let fonts_stack_page = stack.add_titled(&fonts_page, Some("fonts"), "Fonts & Icons"); + fonts_stack_page.set_icon_name(Some("preferences-desktop-font-symbolic")); + let notifications_page = build_notifications_page(&input); let notifications_stack_page = stack.add_titled(¬ifications_page, Some("notifications"), "Notifications"); @@ -224,6 +460,172 @@ fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { scroller.upcast() } +fn build_fonts_page(input: &SettingsEditorInput) -> gtk::Widget { + let page = adw::PreferencesPage::new(); + page.set_title("Fonts & Icons"); + page.set_name(Some("fonts")); + page.set_icon_name(Some("preferences-desktop-font-symbolic")); + page.set_hexpand(true); + page.set_vexpand(true); + + let terminal_group = adw::PreferencesGroup::new(); + terminal_group.set_title("Terminal font size"); + + let terminal_row = adw::ActionRow::builder() + .title("Terminal text") + .subtitle("Default font size for terminal surfaces") + .build(); + terminal_row.set_title_lines(1); + terminal_row.set_subtitle_lines(2); + + let terminal_default_size = crate::terminal::default_font_size(); + let terminal_current_size = input + .config + .borrow() + .font_size + .unwrap_or(terminal_default_size); + let terminal_adjustment = gtk::Adjustment::new( + f64::from(terminal_current_size), + MIN_FONT_SIZE, + MAX_FONT_SIZE, + FONT_SIZE_STEP, + FONT_SIZE_STEP * 2.0, + 0.0, + ); + let terminal_spin = gtk::SpinButton::builder() + .adjustment(&terminal_adjustment) + .digits(1) + .numeric(true) + .valign(gtk::Align::Center) + .width_chars(5) + .build(); + let terminal_reset_button = gtk::Button::builder() + .label("Default") + .tooltip_text("Reset terminal font size") + .valign(gtk::Align::Center) + .build(); + + terminal_row.add_suffix(&terminal_spin); + terminal_row.add_suffix(&terminal_reset_button); + terminal_row.set_activatable_widget(Some(&terminal_spin)); + terminal_group.add(&terminal_row); + page.add(&terminal_group); + + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + let updating_spin = Rc::new(Cell::new(false)); + let updating_spin_for_reset = updating_spin.clone(); + terminal_spin.connect_value_changed(move |spin| { + if updating_spin.get() { + return; + } + let font_size = (spin.value() as f32).clamp(MIN_FONT_SIZE as f32, MAX_FONT_SIZE as f32); + apply_config_change(&config, &*on_changed, move |c| { + c.font_size = Some(font_size); + }); + }); + + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + let terminal_spin = terminal_spin.clone(); + terminal_reset_button.connect_clicked(move |_| { + apply_config_change(&config, &*on_changed, move |c| { + c.font_size = None; + }); + updating_spin_for_reset.set(true); + terminal_spin.set_value(f64::from(terminal_default_size)); + updating_spin_for_reset.set(false); + }); + } + + let group = adw::PreferencesGroup::new(); + group.set_title("UI text and icon sizes"); + group.set_description(Some("Adjust Limux chrome text and pane-header icon sizes.")); + + for descriptor in UI_FONT_DESCRIPTORS { + let row = adw::ActionRow::builder() + .title(descriptor.label) + .subtitle(descriptor.subtitle) + .build(); + row.set_title_lines(1); + row.set_subtitle_lines(2); + + let current_size = input + .config + .borrow() + .ui_font_sizes + .get(descriptor.id) + .copied() + .unwrap_or(descriptor.default_size); + let adjustment = gtk::Adjustment::new( + f64::from(current_size), + MIN_FONT_SIZE, + MAX_FONT_SIZE, + FONT_SIZE_STEP, + FONT_SIZE_STEP * 2.0, + 0.0, + ); + let spin = gtk::SpinButton::builder() + .adjustment(&adjustment) + .digits(1) + .numeric(true) + .valign(gtk::Align::Center) + .width_chars(5) + .build(); + let reset_button = gtk::Button::builder() + .label("Default") + .tooltip_text("Reset this UI font size") + .valign(gtk::Align::Center) + .build(); + + row.add_suffix(&spin); + row.add_suffix(&reset_button); + row.set_activatable_widget(Some(&spin)); + group.add(&row); + + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + let id = descriptor.id; + let updating_spin = Rc::new(Cell::new(false)); + let updating_spin_for_reset = updating_spin.clone(); + spin.connect_value_changed(move |spin| { + if updating_spin.get() { + return; + } + let font_size = (spin.value() as f32).clamp(MIN_FONT_SIZE as f32, MAX_FONT_SIZE as f32); + apply_config_change(&config, &*on_changed, move |c| { + c.ui_font_sizes.insert(id.to_string(), font_size); + }); + }); + + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + let spin = spin.clone(); + let default_size = descriptor.default_size; + reset_button.connect_clicked(move |_| { + apply_config_change(&config, &*on_changed, move |c| { + c.ui_font_sizes.remove(id); + }); + updating_spin_for_reset.set(true); + spin.set_value(f64::from(default_size)); + updating_spin_for_reset.set(false); + }); + } + + page.add(&group); + + let scroller = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .child(&page) + .build(); + scroller.set_hexpand(true); + scroller.set_vexpand(true); + + scroller.upcast() +} + fn build_notifications_page(input: &SettingsEditorInput) -> gtk::Widget { let page = adw::PreferencesPage::new(); page.set_title("Notifications"); @@ -318,4 +720,69 @@ mod tests { assert!(config.borrow().focus.hover_terminal_focus); } + + #[test] + fn ui_font_css_covers_pane_tab_text_and_icons() { + let mut config = AppConfig::default(); + config + .ui_font_sizes + .insert("pane_tab_title".to_string(), 24.0); + config + .ui_font_sizes + .insert("pane_pin_icon".to_string(), 20.0); + config + .ui_font_sizes + .insert("pane_action_icon".to_string(), 18.0); + config + .ui_font_sizes + .insert("pane_tab_close_icon".to_string(), 11.0); + + let css = ui_font_sizes_css(&config); + + assert!(css.contains(".limux-tab { font-size: 24px; }")); + assert!(css.contains(".limux-pin-icon { font-size: 20px; }")); + assert!(css.contains(".limux-tab-status { font-size: 10px; }")); + assert!(css.contains(".limux-tab-rename-entry { font-size: 12px; }")); + assert!(css.contains(".limux-pane-action image { -gtk-icon-size: 18px; }")); + assert!(css.contains(".limux-tab-close image { -gtk-icon-size: 11px; }")); + assert!(UI_FONT_DESCRIPTORS + .iter() + .any(|descriptor| descriptor.id == "pane_tab_title")); + assert!(UI_FONT_DESCRIPTORS + .iter() + .any(|descriptor| descriptor.id == "pane_pin_icon")); + } + + #[test] + fn ui_font_descriptors_cover_sidebar_notification_text() { + let descriptor_ids = UI_FONT_DESCRIPTORS + .iter() + .map(|descriptor| descriptor.id) + .collect::<Vec<_>>(); + + for expected in [ + "sidebar_workspace_name", + "sidebar_workspace_path", + "sidebar_git_branch", + "sidebar_ports", + "pane_tab_title", + "pane_tab_status_icon", + "pane_pin_icon", + "pane_tab_rename_entry", + "pane_action_icon", + "pane_tab_close_icon", + "sidebar_notification_message", + "notification_panel_title", + "notification_panel_empty", + "notification_panel_status", + "notification_panel_workspace", + "notification_panel_message", + "notification_panel_detail", + ] { + assert!( + descriptor_ids.contains(&expected), + "missing UI font descriptor {expected}" + ); + } + } } diff --git a/rust/limux-host-linux/src/shortcut_config.rs b/rust/limux-host-linux/src/shortcut_config.rs index 5aa6079f..7645ab1f 100644 --- a/rust/limux-host-linux/src/shortcut_config.rs +++ b/rust/limux-host-linux/src/shortcut_config.rs @@ -19,6 +19,7 @@ pub enum ShortcutId { QuitApp, NewInstance, ToggleSidebar, + OpenNotificationPanel, ToggleTopBar, ToggleFullscreen, NextWorkspace, @@ -71,6 +72,7 @@ pub enum ShortcutCommand { QuitApp, NewInstance, ToggleSidebar, + OpenNotificationPanel, ToggleTopBar, ToggleFullscreen, NextWorkspace, @@ -311,7 +313,7 @@ struct ShortcutConfigFile { shortcuts: HashMap<String, serde_json::Value>, } -const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 48] = [ +const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 49] = [ ShortcutDefinition { id: ShortcutId::NewWorkspace, config_key: "new_workspace", @@ -367,6 +369,17 @@ const SHORTCUT_DEFINITIONS: [ShortcutDefinition; 48] = [ scope: ShortcutScope::Window, editable_capture_policy: EditableCapturePolicy::BypassInEditable, }, + ShortcutDefinition { + id: ShortcutId::OpenNotificationPanel, + config_key: "open_notification_panel", + action_name: "win.open-notification-panel", + default_accel: "<Ctrl><Shift>i", + label: "Open Notification Panel", + registers_gtk_accel: true, + command: ShortcutCommand::OpenNotificationPanel, + scope: ShortcutScope::Window, + editable_capture_policy: EditableCapturePolicy::BypassInEditable, + }, ShortcutDefinition { id: ShortcutId::ToggleTopBar, config_key: "toggle_top_bar", @@ -1702,7 +1715,7 @@ mod tests { #[test] fn definitions_cover_current_host_shortcuts() { - assert_eq!(definitions().len(), 48); + assert_eq!(definitions().len(), 49); } #[test] @@ -1737,6 +1750,7 @@ mod tests { "app.quit", "app.new-instance", "win.toggle-sidebar", + "win.open-notification-panel", "win.toggle-top-bar", "win.toggle-fullscreen", "win.next-workspace", @@ -2138,7 +2152,7 @@ mod tests { .unwrap(); let gtk_accels = resolved.gtk_accel_entries(); - assert_eq!(gtk_accels.len(), 9); + assert_eq!(gtk_accels.len(), 10); assert_eq!( gtk_accels .iter() diff --git a/rust/limux-host-linux/src/terminal.rs b/rust/limux-host-linux/src/terminal.rs index 4bd00101..8afb28b5 100644 --- a/rust/limux-host-linux/src/terminal.rs +++ b/rust/limux-host-linux/src/terminal.rs @@ -161,6 +161,28 @@ impl TerminalImeState { thread_local! { static SURFACE_MAP: RefCell<HashMap<usize, SurfaceEntry>> = RefCell::new(HashMap::new()); + static HOVER_FOCUS_INHIBIT_COUNT: Cell<u32> = const { Cell::new(0) }; +} + +pub struct HoverFocusInhibitGuard; + +impl Drop for HoverFocusInhibitGuard { + fn drop(&mut self) { + HOVER_FOCUS_INHIBIT_COUNT.with(|count| { + count.set(count.get().saturating_sub(1)); + }); + } +} + +pub fn inhibit_hover_terminal_focus() -> HoverFocusInhibitGuard { + HOVER_FOCUS_INHIBIT_COUNT.with(|count| { + count.set(count.get().saturating_add(1)); + }); + HoverFocusInhibitGuard +} + +fn terminal_focus_inhibited() -> bool { + HOVER_FOCUS_INHIBIT_COUNT.with(|count| count.get() > 0) } #[derive(Clone)] @@ -189,6 +211,9 @@ impl TerminalHandle { pub fn focus_surface(&self) -> bool { self.refresh_display(); + if terminal_focus_inhibited() { + return false; + } self.gl_area.grab_focus(); true } @@ -408,16 +433,22 @@ fn terminal_search_action(query: &str) -> String { } fn request_terminal_focus(gl_area: >k::GLArea, had_focus: &Cell<bool>) { + if terminal_focus_inhibited() { + return; + } had_focus.set(true); gl_area.grab_focus(); } fn refresh_surface_display(surface: ghostty_surface_t, gl_area: >k::GLArea) { let alloc = gl_area.allocation(); - let w = alloc.width() as u32; - let h = alloc.height() as u32; + let scale = gl_area.scale_factor(); + // Ghostty expects physical framebuffer pixels here; GTK allocations are + // logical CSS pixels, so include the integer monitor scale factor. + let w = (alloc.width() * scale) as u32; + let h = (alloc.height() * scale) as u32; if w > 0 && h > 0 { - let scale = gl_area.scale_factor() as f64; + let scale = scale as f64; unsafe { ghostty_surface_set_content_scale(surface, scale, scale); ghostty_surface_set_size(surface, w, h); @@ -1490,7 +1521,9 @@ pub fn create_terminal( if had_focus.get() { let gl_for_focus = gl_for_resize.clone(); glib::idle_add_local_once(move || { - gl_for_focus.grab_focus(); + if !terminal_focus_inhibited() { + gl_for_focus.grab_focus(); + } }); } }); @@ -1684,10 +1717,12 @@ pub fn create_terminal( let had_focus = had_focus.clone(); let motion = gtk::EventControllerMotion::new(); motion.connect_enter(move |ctrl, x, y| { - if (hover_focus)() { + if (hover_focus)() && !terminal_focus_inhibited() { // Match common Hyprland/Omarchy-style focus-follows-mouse behavior: // as soon as the pointer enters a terminal, focus it so typing works - // immediately without an extra click. + // immediately without an extra click. Inline host editors inhibit + // this temporarily so pointer motion caused by closing a popover + // cannot steal focus back from text fields. request_terminal_focus(&gl_for_focus, &had_focus); } @@ -2172,7 +2207,6 @@ fn show_clipboard_toast(overlay: >k::Overlay) { color: white; \ border-radius: 6px; \ padding: 6px 14px; \ - font-size: 12px; \ } \ box.limux-toast label { color: white; } \ box.limux-toast button { \ diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index d089b3b5..1c5db896 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -1,5 +1,5 @@ use std::cell::{Cell, RefCell}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -59,8 +59,34 @@ struct Workspace { /// The folder path this workspace was opened with. folder_path: Option<String>, /// Path label shown below workspace name in sidebar. - #[allow(dead_code)] path_label: gtk::Label, + /// Git branch label shown next to the working directory. + branch_label: gtk::Label, + /// Listening ports label shown next to the working directory. + ports_label: gtk::Label, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct NotificationRecord { + id: u64, + target: DesktopNotificationTarget, + workspace_name: String, + title: String, + subtitle: String, + body: String, + message: String, + kind: NotificationVisualKind, + unread: bool, +} + +struct NotificationRecordDraft<'a> { + title: &'a str, + subtitle: &'a str, + body: &'a str, + message: &'a str, + source_focused: bool, + target: DesktopNotificationTarget, + kind: NotificationVisualKind, } pub(crate) struct AppState { @@ -69,6 +95,7 @@ pub(crate) struct AppState { top_bar: Option<adw::HeaderBar>, top_bar_visible: bool, config: Rc<RefCell<app_config::AppConfig>>, + css_provider: gtk::CssProvider, system_prefers_dark: Rc<Cell<Option<bool>>>, workspaces: Vec<Workspace>, active_idx: usize, @@ -78,6 +105,9 @@ pub(crate) struct AppState { sidebar_shell: gtk::Box, sidebar_handle: gtk::Box, new_ws_btn: gtk::Button, + notification_button: gtk::Button, + notification_records: Vec<NotificationRecord>, + next_notification_id: u64, sidebar_animation: Option<adw::TimedAnimation>, sidebar_animation_epoch: u64, sidebar_expanded_width: i32, @@ -203,6 +233,18 @@ fn parse_pane_handle(raw: &str) -> Option<u32> { normalize_pane_handle(raw).parse::<u32>().ok() } +fn parse_notification_surface_hint(raw: &str) -> Option<(Option<u32>, Option<String>)> { + let normalized = raw + .trim() + .strip_prefix("surface:") + .unwrap_or_else(|| raw.trim()); + let (pane_id, tab_id) = normalized.split_once(':')?; + Some(( + parse_pane_handle(pane_id), + (!tab_id.trim().is_empty()).then(|| tab_id.to_string()), + )) +} + fn workspace_index_for_target(state: &AppState, target: &WorkspaceTarget) -> Option<usize> { match target { WorkspaceTarget::Active => (!state.workspaces.is_empty()).then_some(state.active_idx), @@ -746,6 +788,7 @@ const GNOME_COLOR_SCHEME_KEY: &str = "color-scheme"; const DESKTOP_NOTIFICATION_DBUS_TIMEOUT_MS: i32 = 1_000; const DESKTOP_NOTIFICATION_EXPIRE_TIMEOUT_MS: i32 = 10_000; const PORTAL_THEME_READ_TIMEOUT_MS: i32 = 500; +const MAX_NOTIFICATION_RECORDS: usize = 100; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum PortalColorSchemePreference { @@ -777,6 +820,63 @@ struct DesktopNotificationRequest { target: DesktopNotificationTarget, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NotificationVisualKind { + Attention, + Finished, +} + +impl NotificationVisualKind { + fn as_str(self) -> &'static str { + match self { + Self::Attention => "attention", + Self::Finished => "finished", + } + } + + fn sidebar_row_class(self) -> &'static str { + match self { + Self::Attention => "limux-sidebar-row-attention", + Self::Finished => "limux-sidebar-row-finished", + } + } + + fn sidebar_dot_class(self) -> &'static str { + match self { + Self::Attention => "limux-notify-dot-attention", + Self::Finished => "limux-notify-dot-finished", + } + } + + fn sidebar_message_class(self) -> &'static str { + match self { + Self::Attention => "limux-notify-msg-attention", + Self::Finished => "limux-notify-msg-finished", + } + } + + fn pane_kind(self) -> pane::PaneNotificationKind { + match self { + Self::Attention => pane::PaneNotificationKind::Attention, + Self::Finished => pane::PaneNotificationKind::Finished, + } + } + + fn panel_row_class(self) -> &'static str { + match self { + Self::Attention => "limux-notification-row-attention", + Self::Finished => "limux-notification-row-finished", + } + } + + fn panel_status_class(self) -> &'static str { + match self { + Self::Attention => "limux-notification-status-attention", + Self::Finished => "limux-notification-status-finished", + } + } +} + impl PortalColorSchemePreference { fn from_raw(raw: u32) -> Option<Self> { match raw { @@ -1148,51 +1248,48 @@ const SIDEBAR_HANDLE_CURSOR_NAME: &str = "col-resize"; const SIDEBAR_RESIZE_HANDLE_WIDTH_PX: i32 = 3; const BASE_CSS: &str = r#" -:root { - --limux-host-entry-bg: rgba(255, 255, 255, 0.98); - --limux-host-entry-fg: rgba(15, 23, 42, 0.96); - --limux-host-entry-border: rgba(15, 23, 42, 0.16); - --limux-host-entry-border-focus: rgba(0, 145, 255, 0.72); - --limux-host-entry-placeholder: rgba(15, 23, 42, 0.5); -} -@media (prefers-color-scheme: dark) { - :root { - --limux-host-entry-bg: rgba(44, 44, 48, 0.98); - --limux-host-entry-fg: rgba(255, 255, 255, 0.96); - --limux-host-entry-border: rgba(255, 255, 255, 0.14); - --limux-host-entry-border-focus: rgba(0, 145, 255, 0.78); - --limux-host-entry-placeholder: rgba(255, 255, 255, 0.48); - } -} .limux-host-entry { - background-color: var(--limux-host-entry-bg); - color: var(--limux-host-entry-fg); - border: 1px solid var(--limux-host-entry-border); + background-color: alpha(@window_bg_color, 0.98); + color: @window_fg_color; + border: 1px solid alpha(@window_fg_color, 0.16); border-radius: 6px; caret-color: currentColor; } .limux-host-entry:focus-within { - border-color: var(--limux-host-entry-border-focus); + border-color: alpha(@accent_bg_color, 0.72); } .limux-host-entry text { background-color: transparent; - color: var(--limux-host-entry-fg); + color: @window_fg_color; } .limux-host-entry text placeholder { - color: var(--limux-host-entry-placeholder); + color: alpha(@window_fg_color, 0.5); } .limux-host-entry image { - color: var(--limux-host-entry-placeholder); + color: alpha(@window_fg_color, 0.5); } .limux-sidebar { background-color: @window_bg_color; color: @window_fg_color; border-right: 1px solid alpha(@window_fg_color, 0.08); } +.limux-sidebar .navigation-sidebar > row { + background: transparent; + padding-left: 0; + padding-right: 0; + margin-left: 0; + margin-right: 0; +} .limux-sidebar-row-box { - padding: 8px 6px 8px 3px; - border-radius: 6px; - margin: 2px 3px 2px 1px; + padding: 8px 7px 8px 4px; + border-radius: 7px; + margin: 2px 0; +} +.limux-sidebar .navigation-sidebar > row:selected { + background: transparent; +} +row:selected .limux-sidebar-row-box { + background-color: alpha(@accent_bg_color, 0.18); } .limux-ws-name { color: alpha(@window_fg_color, 0.72); @@ -1226,13 +1323,21 @@ row:selected .limux-ws-star-btn { .limux-notify-dot { color: @accent_bg_color; font-size: 10px; + min-width: 12px; margin-right: 6px; } .limux-notify-dot-hidden { color: transparent; font-size: 10px; + min-width: 12px; margin-right: 6px; } +.limux-notify-dot-attention { + color: @accent_bg_color; +} +.limux-notify-dot-finished { + color: rgb(46, 194, 126); +} .limux-notify-msg { color: alpha(@window_fg_color, 0.35); font-size: 11px; @@ -1241,17 +1346,38 @@ row:selected .limux-ws-star-btn { color: alpha(@accent_bg_color, 0.9); font-size: 11px; } +.limux-notify-msg-attention { + color: alpha(@accent_bg_color, 0.94); + font-weight: 600; +} +.limux-notify-msg-finished { + color: rgb(46, 194, 126); + font-weight: 600; +} .limux-sidebar-row-unread { background-color: alpha(@accent_bg_color, 0.16); - border-left: 3px solid @accent_bg_color; + box-shadow: inset 3px 0 0 0 @accent_bg_color; border-radius: 6px; - margin-left: 0; - margin-right: 0; +} +.limux-sidebar-row-attention { + background-color: alpha(@accent_bg_color, 0.16); + box-shadow: inset 3px 0 0 0 @accent_bg_color; + border-radius: 7px; +} +.limux-sidebar-row-finished { + background-color: rgba(46, 194, 126, 0.14); + box-shadow: inset 3px 0 0 0 rgb(46, 194, 126); + border-radius: 7px; } .limux-sidebar-row-unread .limux-ws-name { color: @window_fg_color; font-weight: 700; } +.limux-sidebar-row-attention .limux-ws-name, +.limux-sidebar-row-finished .limux-ws-name { + color: @window_fg_color; + font-weight: 700; +} .limux-drop-above .limux-sidebar-row-box { border-radius: 0; box-shadow: 0 -2px 0 0 @accent_bg_color; @@ -1273,6 +1399,78 @@ row:selected .limux-ws-star-btn { font-weight: 600; letter-spacing: 1px; } +.limux-notification-button { + background: transparent; + color: alpha(@window_fg_color, 0.54); + border: none; + border-radius: 6px; + padding: 4px; + min-width: 28px; + min-height: 28px; +} +.limux-notification-button:hover { + background: alpha(@window_fg_color, 0.08); + color: @window_fg_color; +} +.limux-notification-button-unread { + color: @accent_color; + background: alpha(@accent_bg_color, 0.12); +} +.limux-notification-panel { + background-color: @popover_bg_color; + color: @popover_fg_color; + min-width: 340px; + padding: 8px; +} +.limux-notification-panel-title { + color: alpha(@popover_fg_color, 0.72); + font-size: 12px; + font-weight: 700; +} +.limux-notification-empty { + color: alpha(@popover_fg_color, 0.45); + font-size: 12px; + padding: 16px; +} +.limux-notification-row { + padding: 8px; + border-radius: 7px; +} +.limux-notification-row:hover { + background: alpha(@popover_fg_color, 0.08); +} +.limux-notification-row-unread { + background: alpha(@accent_bg_color, 0.10); +} +.limux-notification-row-attention { + box-shadow: inset 3px 0 0 0 @accent_bg_color; +} +.limux-notification-row-finished { + box-shadow: inset 3px 0 0 0 rgb(46, 194, 126); +} +.limux-notification-status { + font-size: 10px; + min-width: 12px; +} +.limux-notification-status-attention { + color: @accent_color; +} +.limux-notification-status-finished { + color: rgb(46, 194, 126); +} +.limux-notification-workspace { + color: alpha(@popover_fg_color, 0.48); + font-size: 11px; +} +.limux-notification-message { + color: @popover_fg_color; + font-size: 12px; + font-weight: 600; +} +.limux-notification-detail { + color: alpha(@popover_fg_color, 0.56); + font-size: 11px; +} .limux-sidebar-btn { background: alpha(@window_fg_color, 0.08); color: alpha(@window_fg_color, 0.7); @@ -1311,9 +1509,36 @@ row:selected .limux-ws-star-btn { color: alpha(@window_fg_color, 0.3); font-size: 12px; } +.limux-ws-meta-row { + margin-left: 8px; +} +.limux-ws-branch { + background-color: alpha(@accent_bg_color, 0.13); + color: alpha(@accent_color, 0.95); + border-radius: 5px; + padding: 1px 5px; + font-size: 11px; + font-weight: 600; +} +.limux-ws-ports { + background-color: alpha(@window_fg_color, 0.08); + color: alpha(@window_fg_color, 0.58); + border-radius: 5px; + padding: 1px 5px; + font-size: 11px; + font-weight: 600; +} row:selected .limux-ws-path { color: alpha(@window_fg_color, 0.5); } +row:selected .limux-ws-branch { + background-color: alpha(@accent_bg_color, 0.22); + color: @accent_color; +} +row:selected .limux-ws-ports { + background-color: alpha(@window_fg_color, 0.12); + color: alpha(@window_fg_color, 0.72); +} .limux-content { background-color: @window_bg_color; } @@ -1328,6 +1553,26 @@ row:selected .limux-ws-path { const CONTENT_BACKGROUND_RGB: (u8, u8, u8) = (23, 23, 23); +fn app_css(background_opacity: f64, config: &app_config::AppConfig) -> String { + format!( + "{}\n{}\n{}\n{}\n{}", + build_window_css(background_opacity), + pane::PANE_CSS, + keybind_editor::KEYBIND_EDITOR_CSS, + crate::settings_editor::SETTINGS_CSS, + crate::settings_editor::ui_font_sizes_css(config), + ) +} + +fn reload_app_css(state: &State, config: &app_config::AppConfig) { + let background_opacity = + sanitize_background_opacity(crate::terminal::ghostty_background_opacity()); + state + .borrow() + .css_provider + .load_from_data(&app_css(background_opacity, config)); +} + // --------------------------------------------------------------------------- // Window construction // --------------------------------------------------------------------------- @@ -1355,13 +1600,7 @@ pub fn build_window(app: &adw::Application) { // Load CSS let provider = gtk::CssProvider::new(); - let all_css = format!( - "{}\n{}\n{}\n{}", - build_window_css(background_opacity), - pane::PANE_CSS, - keybind_editor::KEYBIND_EDITOR_CSS, - crate::settings_editor::SETTINGS_CSS, - ); + let all_css = app_css(background_opacity, &config.borrow()); provider.load_from_data(&all_css); gtk::style_context_add_provider_for_display( &display, @@ -1452,6 +1691,13 @@ pub fn build_window(app: &adw::Application) { .build(); sidebar_title_label.add_css_class("limux-sidebar-title"); + let notification_button = + gtk::Button::from_icon_name("preferences-system-notifications-symbolic"); + notification_button.add_css_class("limux-notification-button"); + notification_button.set_tooltip_text(Some( + &shortcuts.tooltip_text(ShortcutId::OpenNotificationPanel, "Notifications"), + )); + let sidebar_title = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .margin_top(8) @@ -1459,6 +1705,7 @@ pub fn build_window(app: &adw::Application) { .margin_end(6) .build(); sidebar_title.append(&sidebar_title_label); + sidebar_title.append(¬ification_button); { let window = window.clone(); @@ -1533,6 +1780,7 @@ pub fn build_window(app: &adw::Application) { top_bar: header.clone(), top_bar_visible: true, config, + css_provider: provider.clone(), system_prefers_dark: system_prefers_dark.clone(), workspaces: Vec::new(), active_idx: 0, @@ -1542,6 +1790,9 @@ pub fn build_window(app: &adw::Application) { sidebar_shell: sidebar_shell.clone(), sidebar_handle: sidebar_handle.clone(), new_ws_btn: new_ws_btn.clone(), + notification_button: notification_button.clone(), + notification_records: Vec::new(), + next_notification_id: 1, sidebar_animation: None, sidebar_animation_epoch: 0, sidebar_expanded_width: SIDEBAR_WIDTH, @@ -1562,6 +1813,13 @@ pub fn build_window(app: &adw::Application) { install_sidebar_resize(&state, &main_split, &sidebar, &sidebar_shell); + { + let state = state.clone(); + notification_button.connect_clicked(move |_| { + show_notification_panel(&state); + }); + } + { let state = state.clone(); let system_prefers_dark = system_prefers_dark.clone(); @@ -1697,6 +1955,7 @@ pub fn build_window(app: &adw::Application) { } apply_loaded_session(&state, layout_state::load_session()); + install_sidebar_port_refresh(&state); crate::control_bridge::start(dispatch_control_command); @@ -2074,6 +2333,10 @@ fn dispatch_shortcut_command(state: &State, command: ShortcutCommand) -> bool { toggle_sidebar(state); true } + ShortcutCommand::OpenNotificationPanel => { + show_notification_panel(state); + true + } ShortcutCommand::ToggleTopBar => { toggle_top_bar(state); true @@ -2201,11 +2464,12 @@ fn apply_shortcuts_to_application(app: &adw::Application, shortcuts: &ResolvedSh } fn apply_shortcut_config(state: &State, shortcuts: ResolvedShortcutConfig) { - let (app, workspace_roots, shortcuts_rc) = { + let (app, notification_button, workspace_roots, shortcuts_rc) = { let mut s = state.borrow_mut(); s.shortcuts = Rc::new(shortcuts); ( s.app.clone(), + s.notification_button.clone(), s.workspaces .iter() .map(|ws| ws.root.clone()) @@ -2215,6 +2479,9 @@ fn apply_shortcut_config(state: &State, shortcuts: ResolvedShortcutConfig) { }; apply_shortcuts_to_application(&app, &shortcuts_rc); + notification_button.set_tooltip_text(Some( + &shortcuts_rc.tooltip_text(ShortcutId::OpenNotificationPanel, "Notifications"), + )); for root in workspace_roots { refresh_shortcut_tooltips_in_layout(&root, &shortcuts_rc); } @@ -2724,6 +2991,274 @@ fn focus_desktop_notification_target(state: &State, target: &DesktopNotification false } +fn set_notification_button_unread(button: >k::Button, unread: bool) { + if unread { + button.add_css_class("limux-notification-button-unread"); + } else { + button.remove_css_class("limux-notification-button-unread"); + } +} + +fn sync_notification_button_state(state: &AppState) { + set_notification_button_unread( + &state.notification_button, + state + .notification_records + .iter() + .any(|record| record.unread), + ); +} + +fn append_notification_record(state: &mut AppState, record: NotificationRecord) { + state.notification_records.push(record); + let overflow = state + .notification_records + .len() + .saturating_sub(MAX_NOTIFICATION_RECORDS); + if overflow > 0 { + state.notification_records.drain(0..overflow); + } + sync_notification_button_state(state); +} + +fn record_notification_for_workspace( + state: &State, + ws_id: &str, + draft: NotificationRecordDraft<'_>, +) { + let mut s = state.borrow_mut(); + let Some(workspace_idx) = s + .workspaces + .iter() + .position(|workspace| workspace.id == ws_id) + else { + return; + }; + + let id = s.next_notification_id; + s.next_notification_id += 1; + let workspace_name = s.workspaces[workspace_idx].name.clone(); + let unread = + should_show_workspace_unread_marker(workspace_idx == s.active_idx, draft.source_focused); + append_notification_record( + &mut s, + NotificationRecord { + id, + target: draft.target, + workspace_name, + title: draft.title.trim().to_string(), + subtitle: draft.subtitle.trim().to_string(), + body: draft.body.trim().to_string(), + message: draft.message.trim().to_string(), + kind: draft.kind, + unread, + }, + ); +} + +fn mark_notification_records_read_in_state(state: &mut AppState, ws_id: &str) { + for record in &mut state.notification_records { + if record.target.workspace_id == ws_id { + record.unread = false; + } + } + sync_notification_button_state(state); +} + +fn mark_workspace_notifications_read(state: &State, ws_id: &str) { + let mut s = state.borrow_mut(); + if let Some(idx) = s + .workspaces + .iter() + .position(|workspace| workspace.id == ws_id) + { + let workspace = &mut s.workspaces[idx]; + workspace.unread = false; + clear_workspace_notification_visuals(workspace); + } + mark_notification_records_read_in_state(&mut s, ws_id); +} + +fn clear_notification_records(state: &State) { + let mut s = state.borrow_mut(); + s.notification_records.clear(); + for workspace in &mut s.workspaces { + workspace.unread = false; + clear_workspace_notification_visuals(workspace); + } + sync_notification_button_state(&s); +} + +fn notification_detail_text(record: &NotificationRecord) -> Option<String> { + let subtitle = record.subtitle.trim(); + let body = record.body.trim(); + let detail = match (subtitle.is_empty(), body.is_empty()) { + (true, true) => "", + (true, false) => body, + (false, true) => subtitle, + (false, false) => return Some(format!("{subtitle} - {body}")), + }; + + (!detail.is_empty() && detail != record.message.trim()).then(|| detail.to_string()) +} + +fn build_notification_record_row(record: &NotificationRecord) -> gtk::ListBoxRow { + let status = gtk::Label::builder() + .label("\u{25CF}") + .valign(gtk::Align::Start) + .build(); + status.add_css_class("limux-notification-status"); + status.add_css_class(record.kind.panel_status_class()); + + let workspace = gtk::Label::builder() + .label(&record.workspace_name) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + workspace.add_css_class("limux-notification-workspace"); + + let primary_message = if record.message.is_empty() { + record.title.as_str() + } else { + record.message.as_str() + }; + let message = gtk::Label::builder() + .label(primary_message) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + message.add_css_class("limux-notification-message"); + + let text = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .hexpand(true) + .build(); + text.append(&workspace); + text.append(&message); + + if let Some(detail) = notification_detail_text(record) { + let detail = gtk::Label::builder() + .label(&detail) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + detail.add_css_class("limux-notification-detail"); + text.append(&detail); + } + + let row_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + row_box.add_css_class("limux-notification-row"); + row_box.add_css_class(record.kind.panel_row_class()); + if record.unread { + row_box.add_css_class("limux-notification-row-unread"); + } + row_box.append(&status); + row_box.append(&text); + + let row = gtk::ListBoxRow::new(); + row.set_selectable(false); + row.set_activatable(true); + row.set_child(Some(&row_box)); + row +} + +fn show_notification_panel(state: &State) { + let (button, records) = { + let s = state.borrow(); + ( + s.notification_button.clone(), + s.notification_records + .iter() + .rev() + .cloned() + .collect::<Vec<_>>(), + ) + }; + + let popover = gtk::Popover::new(); + popover.set_parent(&button); + popover.set_position(gtk::PositionType::Right); + popover.connect_closed(|popover| popover.unparent()); + + let title = gtk::Label::builder() + .label("Notifications") + .xalign(0.0) + .hexpand(true) + .build(); + title.add_css_class("limux-notification-panel-title"); + + let clear_button = gtk::Button::from_icon_name("edit-clear-symbolic"); + clear_button.add_css_class("limux-notification-button"); + clear_button.set_tooltip_text(Some("Clear notifications")); + clear_button.set_sensitive(!records.is_empty()); + { + let state = state.clone(); + let popover = popover.clone(); + clear_button.connect_clicked(move |_| { + clear_notification_records(&state); + popover.popdown(); + }); + } + + let header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + header.append(&title); + header.append(&clear_button); + + let panel = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + panel.add_css_class("limux-notification-panel"); + panel.append(&header); + + if records.is_empty() { + let empty = gtk::Label::builder() + .label("No notifications") + .xalign(0.5) + .build(); + empty.add_css_class("limux-notification-empty"); + panel.append(&empty); + } else { + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::None); + for record in records { + let row = build_notification_record_row(&record); + let target = record.target.clone(); + let state = state.clone(); + let popover = popover.clone(); + let click = gtk::GestureClick::new(); + click.set_button(1); + click.connect_released(move |gesture, _, _, _| { + mark_workspace_notifications_read(&state, &target.workspace_id); + activate_desktop_notification_target(&state, &target, None); + popover.popdown(); + gesture.set_state(gtk::EventSequenceState::Claimed); + }); + row.add_controller(click); + list.append(&row); + } + + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .min_content_height(80) + .max_content_height(360) + .child(&list) + .build(); + panel.append(&scroll); + } + + popover.set_child(Some(&panel)); + popover.popup(); +} + fn connect_gnome_appearance_watch( settings: &gio::Settings, state: State, @@ -2819,98 +3354,379 @@ fn activate_last_workspace_shortcut(state: &State) { activate_workspace_shortcut(state, last_idx); } -// --------------------------------------------------------------------------- -// Sidebar row -// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Sidebar row +// --------------------------------------------------------------------------- + +fn build_sidebar_row( + name: &str, + folder_path: Option<&str>, +) -> ( + gtk::ListBoxRow, + gtk::Label, + gtk::Button, + gtk::Label, + gtk::Label, + gtk::Label, + gtk::Label, + gtk::Label, +) { + let notify_dot = gtk::Label::builder().label("\u{25CF}").build(); + notify_dot.add_css_class("limux-notify-dot-hidden"); + + let name_label = gtk::Label::builder() + .label(name) + .xalign(0.0) + .hexpand(true) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + name_label.add_css_class("limux-ws-name"); + + let favorite_button = gtk::Button::with_label("\u{2606}"); + favorite_button.add_css_class("flat"); + favorite_button.add_css_class("limux-ws-star-btn"); + favorite_button.set_focus_on_click(false); + favorite_button.set_valign(gtk::Align::Center); + favorite_button.set_halign(gtk::Align::End); + favorite_button.set_tooltip_text(Some("Favorite workspace")); + + let top_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + top_row.append(¬ify_dot); + top_row.append(&name_label); + top_row.append(&favorite_button); + + let path_label = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .hexpand(true) + .build(); + path_label.add_css_class("limux-ws-path"); + + let branch_label = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(14) + .visible(false) + .build(); + branch_label.add_css_class("limux-ws-branch"); + + let ports_label = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .max_width_chars(18) + .visible(false) + .build(); + ports_label.add_css_class("limux-ws-ports"); + + let meta_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(5) + .build(); + meta_row.add_css_class("limux-ws-meta-row"); + meta_row.append(&path_label); + meta_row.append(&branch_label); + meta_row.append(&ports_label); + update_sidebar_location_labels(&path_label, &branch_label, folder_path); + update_sidebar_ports_label(&ports_label, &[]); + + let notify_label = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .visible(false) + .margin_start(8) + .build(); + notify_label.add_css_class("limux-notify-msg"); + + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(2) + .build(); + vbox.add_css_class("limux-sidebar-row-box"); + vbox.append(&top_row); + vbox.append(&meta_row); + vbox.append(¬ify_label); + + let row = gtk::ListBoxRow::new(); + row.set_child(Some(&vbox)); + + ( + row, + name_label, + favorite_button, + notify_dot, + notify_label, + path_label, + branch_label, + ports_label, + ) +} + +/// Abbreviate a path by replacing the home directory with ~. +fn abbreviate_path(path: &str) -> String { + if let Some(home) = dirs::home_dir() { + let home_str = home.to_string_lossy(); + if path.starts_with(home_str.as_ref()) { + return format!("~{}", &path[home_str.len()..]); + } + } + path.to_string() +} + +fn update_sidebar_location_labels( + path_label: >k::Label, + branch_label: >k::Label, + path: Option<&str>, +) { + let Some(path) = path.filter(|path| !path.trim().is_empty()) else { + path_label.set_visible(false); + branch_label.set_visible(false); + return; + }; + + path_label.set_label(&abbreviate_path(path)); + path_label.set_tooltip_text(Some(path)); + path_label.set_visible(true); + + if let Some(branch) = git_branch_for_path(path) { + branch_label.set_label(&branch); + branch_label.set_tooltip_text(Some(&format!("Git branch: {branch}"))); + branch_label.set_visible(true); + } else { + branch_label.set_visible(false); + } +} + +fn update_workspace_location_visuals(workspace: &Workspace, path: Option<&str>) { + update_sidebar_location_labels(&workspace.path_label, &workspace.branch_label, path); +} + +fn update_sidebar_ports_label(ports_label: >k::Label, ports: &[u16]) { + if ports.is_empty() { + ports_label.set_visible(false); + ports_label.set_tooltip_text(None); + return; + } + + let label = sidebar_ports_summary(ports); + let tooltip = ports + .iter() + .map(|port| format!("localhost:{port}")) + .collect::<Vec<_>>() + .join("\n"); + ports_label.set_label(&label); + ports_label.set_tooltip_text(Some(&tooltip)); + ports_label.set_visible(true); +} + +fn sidebar_ports_summary(ports: &[u16]) -> String { + let Some(first) = ports.first() else { + return String::new(); + }; + + if ports.len() == 1 { + format!("localhost:{first}") + } else { + format!("localhost:{first} +{}", ports.len() - 1) + } +} + +fn install_sidebar_port_refresh(state: &State) { + refresh_sidebar_ports(state); + + let state = state.clone(); + glib::timeout_add_seconds_local(5, move || { + refresh_sidebar_ports(&state); + glib::ControlFlow::Continue + }); +} + +fn refresh_sidebar_ports(state: &State) { + let workspace_paths = { + let s = state.borrow(); + s.workspaces + .iter() + .filter_map(|workspace| { + let path = workspace + .cwd + .borrow() + .clone() + .or_else(|| workspace.folder_path.clone())?; + Some((workspace.id.clone(), PathBuf::from(path))) + }) + .collect::<Vec<_>>() + }; + + if workspace_paths.is_empty() { + return; + } + + let ports_by_workspace = workspace_listening_ports(&workspace_paths); + let s = state.borrow(); + for workspace in &s.workspaces { + let ports = ports_by_workspace + .get(&workspace.id) + .map(Vec::as_slice) + .unwrap_or(&[]); + update_sidebar_ports_label(&workspace.ports_label, ports); + } +} + +fn workspace_listening_ports(workspace_paths: &[(String, PathBuf)]) -> HashMap<String, Vec<u16>> { + let listening = proc_listening_socket_ports(); + if listening.is_empty() { + return HashMap::new(); + } + + let mut ports_by_workspace: HashMap<String, HashSet<u16>> = HashMap::new(); + let Ok(entries) = std::fs::read_dir("/proc") else { + return HashMap::new(); + }; + + for entry in entries.flatten() { + let Some(pid_name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if !pid_name.chars().all(|ch| ch.is_ascii_digit()) { + continue; + } + + let proc_dir = entry.path(); + let Ok(process_cwd) = std::fs::read_link(proc_dir.join("cwd")) else { + continue; + }; + let Some(workspace_id) = workspace_paths + .iter() + .find(|(_, workspace_path)| path_is_within(&process_cwd, workspace_path)) + .map(|(workspace_id, _)| workspace_id.clone()) + else { + continue; + }; + + let Ok(fds) = std::fs::read_dir(proc_dir.join("fd")) else { + continue; + }; + for fd in fds.flatten() { + let Ok(target) = std::fs::read_link(fd.path()) else { + continue; + }; + let Some(inode) = target.to_str().and_then(parse_proc_fd_socket_inode) else { + continue; + }; + if let Some(port) = listening.get(&inode).copied() { + ports_by_workspace + .entry(workspace_id.clone()) + .or_default() + .insert(port); + } + } + } + + ports_by_workspace + .into_iter() + .map(|(workspace_id, ports)| { + let mut ports = ports.into_iter().collect::<Vec<_>>(); + ports.sort_unstable(); + (workspace_id, ports) + }) + .collect() +} -fn build_sidebar_row( - name: &str, - folder_path: Option<&str>, -) -> ( - gtk::ListBoxRow, - gtk::Label, - gtk::Button, - gtk::Label, - gtk::Label, - gtk::Label, -) { - let notify_dot = gtk::Label::builder().label("\u{25CF}").build(); - notify_dot.add_css_class("limux-notify-dot-hidden"); +fn path_is_within(path: &Path, ancestor: &Path) -> bool { + let Ok(path) = path.canonicalize() else { + return false; + }; + let Ok(ancestor) = ancestor.canonicalize() else { + return false; + }; + path.starts_with(ancestor) +} - let name_label = gtk::Label::builder() - .label(name) - .xalign(0.0) - .hexpand(true) - .ellipsize(gtk::pango::EllipsizeMode::End) - .build(); - name_label.add_css_class("limux-ws-name"); +fn proc_listening_socket_ports() -> HashMap<u64, u16> { + ["/proc/net/tcp", "/proc/net/tcp6"] + .into_iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .flat_map(|raw| parse_proc_net_listening_ports(&raw)) + .collect() +} - let favorite_button = gtk::Button::with_label("\u{2606}"); - favorite_button.add_css_class("flat"); - favorite_button.add_css_class("limux-ws-star-btn"); - favorite_button.set_focus_on_click(false); - favorite_button.set_valign(gtk::Align::Center); - favorite_button.set_halign(gtk::Align::End); - favorite_button.set_tooltip_text(Some("Favorite workspace")); +fn parse_proc_net_listening_ports(raw: &str) -> Vec<(u64, u16)> { + raw.lines() + .skip(1) + .filter_map(parse_proc_net_tcp_line) + .collect() +} - let top_row = gtk::Box::new(gtk::Orientation::Horizontal, 0); - top_row.append(¬ify_dot); - top_row.append(&name_label); - top_row.append(&favorite_button); +fn parse_proc_net_tcp_line(line: &str) -> Option<(u64, u16)> { + let columns = line.split_whitespace().collect::<Vec<_>>(); + if columns.len() <= 9 || columns[3] != "0A" { + return None; + } - let path_label = gtk::Label::builder() - .xalign(0.0) - .ellipsize(gtk::pango::EllipsizeMode::End) - .margin_start(8) - .build(); - path_label.add_css_class("limux-ws-path"); - if let Some(p) = folder_path { - path_label.set_label(&abbreviate_path(p)); - path_label.set_tooltip_text(Some(p)); - path_label.set_visible(true); - } else { - path_label.set_visible(false); + let (_, port_hex) = columns[1].split_once(':')?; + let port = u16::from_str_radix(port_hex, 16).ok()?; + if port == 0 { + return None; } - let notify_label = gtk::Label::builder() - .xalign(0.0) - .ellipsize(gtk::pango::EllipsizeMode::End) - .visible(false) - .margin_start(8) - .build(); - notify_label.add_css_class("limux-notify-msg"); + let inode = columns[9].parse::<u64>().ok()?; + Some((inode, port)) +} - let vbox = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(2) - .build(); - vbox.add_css_class("limux-sidebar-row-box"); - vbox.append(&top_row); - vbox.append(&path_label); - vbox.append(¬ify_label); +fn parse_proc_fd_socket_inode(target: &str) -> Option<u64> { + target + .strip_prefix("socket:[") + .and_then(|value| value.strip_suffix(']')) + .and_then(|inode| inode.parse::<u64>().ok()) +} - let row = gtk::ListBoxRow::new(); - row.set_child(Some(&vbox)); +fn git_branch_for_path(path: &str) -> Option<String> { + let mut current = Path::new(path); + loop { + let dot_git = current.join(".git"); + if dot_git.is_dir() { + return git_branch_from_git_dir(&dot_git); + } + if dot_git.is_file() { + return git_branch_from_git_file(current, &dot_git); + } + current = current.parent()?; + } +} - ( - row, - name_label, - favorite_button, - notify_dot, - notify_label, - path_label, - ) +fn git_branch_from_git_file(worktree_dir: &Path, dot_git_file: &Path) -> Option<String> { + let raw = std::fs::read_to_string(dot_git_file).ok()?; + let gitdir = raw.trim().strip_prefix("gitdir:")?.trim(); + let gitdir = Path::new(gitdir); + let gitdir = if gitdir.is_absolute() { + gitdir.to_path_buf() + } else { + worktree_dir.join(gitdir) + }; + git_branch_from_git_dir(&gitdir) } -/// Abbreviate a path by replacing the home directory with ~. -fn abbreviate_path(path: &str) -> String { - if let Some(home) = dirs::home_dir() { - let home_str = home.to_string_lossy(); - if path.starts_with(home_str.as_ref()) { - return format!("~{}", &path[home_str.len()..]); - } +fn git_branch_from_git_dir(git_dir: &Path) -> Option<String> { + let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?; + git_branch_from_head(&head) +} + +fn git_branch_from_head(head: &str) -> Option<String> { + let head = head.trim(); + if let Some(reference) = head.strip_prefix("ref:") { + let reference = reference.trim(); + return reference + .strip_prefix("refs/heads/") + .or_else(|| reference.rsplit('/').next()) + .map(str::trim) + .filter(|branch| !branch.is_empty()) + .map(ToOwned::to_owned); } - path.to_string() + + if head.len() >= 7 && head.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Some(format!("@{}", &head[..7])); + } + + None } // --------------------------------------------------------------------------- @@ -3422,8 +4238,17 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { let split_container = SplitTreeContainer::new(state, pane.clone().upcast()); let root = split_container.widget().clone(); - let (row, name_label, favorite_button, notify_dot, notify_label, path_label) = - build_sidebar_row(&seed.name, seed.folder_path.as_deref()); + let sidebar_location = seed.folder_path.as_deref().or(seed.cwd.as_deref()); + let ( + row, + name_label, + favorite_button, + notify_dot, + notify_label, + path_label, + branch_label, + ports_label, + ) = build_sidebar_row(&seed.name, sidebar_location); let row_clone = row.clone(); { let mut app_state = state.borrow_mut(); @@ -3446,6 +4271,8 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { cwd: Rc::new(RefCell::new(seed.cwd.clone())), folder_path: seed.folder_path.clone(), path_label, + branch_label, + ports_label, }); app_state.active_idx = app_state.workspaces.len() - 1; app_state.stack.set_visible_child_name(&stack_name); @@ -3457,6 +4284,7 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { } if pane::move_tab_to_pane(&source_pane, tab_id, &pane.clone().upcast()) { + refresh_sidebar_ports(state); request_session_save(state); return true; } @@ -4439,6 +5267,8 @@ fn handle_control_command(state: &State, command: ControlCommand) { } ControlCommand::CreateNotification { target, + surface_hint, + kind, title, subtitle, body, @@ -4468,15 +5298,43 @@ fn handle_control_command(state: &State, command: ControlCommand) { (false, true) => subtitle.clone(), (false, false) => format!("{subtitle} — {body}"), }; + let visual_kind = notification_visual_kind(kind.as_deref(), &title, &combined_body); let message = workspace_notification_message(&title, &combined_body); + let (pane_id, tab_id) = surface_hint + .as_deref() + .and_then(parse_notification_surface_hint) + .unwrap_or((None, None)); + if let (Some(pane_id), Some(tab_id)) = (pane_id, tab_id.as_deref()) { + if let Some(pane_widget) = pane::find_pane_widget_by_id(pane_id) { + pane::mark_tab_notification(&pane_widget, tab_id, visual_kind.pane_kind()); + } + } let target = DesktopNotificationTarget { workspace_id: ws_id.clone(), - pane_id: None, - tab_id: None, + pane_id, + tab_id, }; - if let Some(request) = - mark_workspace_unread_with_message(state, &ws_id, &message, false, target) - { + record_notification_for_workspace( + state, + &ws_id, + NotificationRecordDraft { + title: &title, + subtitle: &subtitle, + body: &body, + message: &message, + source_focused: false, + target: target.clone(), + kind: visual_kind, + }, + ); + if let Some(request) = mark_workspace_unread_with_message( + state, + &ws_id, + &message, + false, + target, + visual_kind, + ) { show_desktop_notification(state, request); } @@ -4484,6 +5342,7 @@ fn handle_control_command(state: &State, command: ControlCommand) { "ok": true, "workspace_id": ws_id, "workspace_ref": workspace_ref(&ws_id), + "kind": kind.unwrap_or_else(|| visual_kind.as_str().to_string()), "title": title, "subtitle": subtitle, "body": body, @@ -4517,8 +5376,16 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { build_workspace_root(state, &shortcuts, &id, working_dir, &workspace.layout); stack.add_named(&root, Some(&stack_name)); - let (row, name_label, favorite_button, notify_dot, notify_label, path_label) = - build_sidebar_row(&workspace.name, workspace.folder_path.as_deref()); + let ( + row, + name_label, + favorite_button, + notify_dot, + notify_label, + path_label, + branch_label, + ports_label, + ) = build_sidebar_row(&workspace.name, working_dir); sidebar_list.append(&row); install_workspace_row_interactions(state, &id, &row, &favorite_button); @@ -4538,6 +5405,8 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { cwd, folder_path: workspace.folder_path.clone(), path_label, + branch_label, + ports_label, }; if workspace.favorite { @@ -4552,6 +5421,7 @@ fn add_workspace_from_state(state: &State, workspace: &WorkspaceState) { stack.set_visible_child_name(&stack_name); sidebar_list.select_row(Some(&row)); + refresh_sidebar_ports(state); } /// Create a PaneWidget wired up with callbacks for a specific workspace. @@ -4627,14 +5497,31 @@ pub(crate) fn create_pane_for_workspace( pane_id: Some(pane_id), tab_id: Some(tab_id), }; + let visual_kind = notification_visual_kind(None, title, body); let message = workspace_notification_message(title, body); + let title = title.to_string(); + let body = body.to_string(); glib::idle_add_local_once(move || { + record_notification_for_workspace( + &state, + &ws_id, + NotificationRecordDraft { + title: &title, + subtitle: "", + body: &body, + message: &message, + source_focused, + target: target.clone(), + kind: visual_kind, + }, + ); if let Some(request) = mark_workspace_unread_with_message( &state, &ws_id, &message, source_focused, target, + visual_kind, ) { show_desktop_notification(&state, request); } @@ -4663,9 +5550,18 @@ pub(crate) fn create_pane_for_workspace( let ws_id = ws_id_pwd.clone(); let pwd = pwd.to_string(); glib::idle_add_local_once(move || { - let s = state.borrow(); - if let Some(ws) = s.workspaces.iter().find(|w| w.id == ws_id) { - *ws.cwd.borrow_mut() = Some(pwd); + let updated = { + let s = state.borrow(); + if let Some(ws) = s.workspaces.iter().find(|w| w.id == ws_id) { + *ws.cwd.borrow_mut() = Some(pwd.clone()); + update_workspace_location_visuals(ws, Some(&pwd)); + true + } else { + false + } + }; + if updated { + refresh_sidebar_ports(&state); } }); }), @@ -4700,6 +5596,12 @@ pub(crate) fn create_pane_for_workspace( let system_prefers_dark = state_for_config_changed.borrow().system_prefers_dark.get(); apply_appearance(&style_manager, system_prefers_dark, &updated.appearance); + if updated.font_size != previous.font_size { + apply_saved_font_size(updated.font_size); + } + if updated.ui_font_sizes != previous.ui_font_sizes { + reload_app_css(&state_for_config_changed, updated); + } if let Err(err) = app_config::save(updated) { state_for_config_changed .borrow() @@ -4707,6 +5609,12 @@ pub(crate) fn create_pane_for_workspace( .borrow_mut() .clone_from(previous); apply_appearance(&style_manager, system_prefers_dark, &previous.appearance); + if updated.font_size != previous.font_size { + apply_saved_font_size(previous.font_size); + } + if updated.ui_font_sizes != previous.ui_font_sizes { + reload_app_css(&state_for_config_changed, previous); + } let detail = format!("Failed to save Limux settings: {err}"); eprintln!("limux: {detail}"); @@ -4761,6 +5669,9 @@ fn close_workspace_by_id_internal( let ws = s.workspaces.remove(idx); s.stack.remove(&ws.root); s.sidebar_list.remove(&ws.sidebar_row); + s.notification_records + .retain(|record| record.target.workspace_id != id); + sync_notification_button_state(&s); if s.workspaces.is_empty() { s.active_idx = 0; @@ -4797,29 +5708,25 @@ fn close_workspace_by_id_internal( } fn switch_workspace(state: &State, idx: usize) { - let (stack, stack_name, unread_handles, focus_root) = { + let (stack, stack_name, focus_root) = { let mut s = state.borrow_mut(); if idx >= s.workspaces.len() || idx == s.active_idx { return; } s.active_idx = idx; let stack = s.stack.clone(); - let stack_name = format!("ws-{}", s.workspaces[idx].id); + let ws_id = s.workspaces[idx].id.clone(); + let stack_name = format!("ws-{ws_id}"); let focus_root = s.workspaces[idx].root.clone(); - let unread_handles = if s.workspaces[idx].unread { + if s.workspaces[idx].unread { let ws = &mut s.workspaces[idx]; ws.unread = false; - Some(( - ws.notify_dot.clone(), - ws.notify_label.clone(), - ws.sidebar_row.clone(), - )) - } else { - None - }; + clear_workspace_notification_visuals(ws); + } + mark_notification_records_read_in_state(&mut s, &ws_id); - (stack, stack_name, unread_handles, focus_root) + (stack, stack_name, focus_root) }; stack.set_visible_child_name(&stack_name); @@ -4827,17 +5734,6 @@ fn switch_workspace(state: &State, idx: usize) { focus_workspace_entrypoint(&focus_root); }); - if let Some((notify_dot, notify_label, sidebar_row)) = unread_handles { - notify_dot.remove_css_class("limux-notify-dot"); - notify_dot.add_css_class("limux-notify-dot-hidden"); - notify_label.remove_css_class("limux-notify-msg-unread"); - notify_label.add_css_class("limux-notify-msg"); - notify_label.set_visible(false); - if let Some(row_box) = sidebar_row.child() { - row_box.remove_css_class("limux-sidebar-row-unread"); - } - } - request_session_save(state); } @@ -5308,7 +6204,7 @@ fn persist_font_size(state: &State, font_size: Option<f32>) -> Result<(), String } fn font_size_after_delta(current: Option<f32>, default: f32, delta: f32) -> f32 { - (current.unwrap_or(default) + delta).clamp(1.0, 255.0) + (current.unwrap_or(default) + delta).clamp(8.0, 255.0) } fn show_font_size_save_error(state: &State, err: String) { @@ -5317,6 +6213,13 @@ fn show_font_size_save_error(state: &State, err: String) { show_runtime_error(state, "Failed to save settings", &detail); } +fn apply_saved_font_size(font_size: Option<f32>) { + match font_size { + Some(size) => broadcast_font_size(size), + None => crate::terminal::broadcast_binding_action("reset_font_size"), + } +} + fn broadcast_font_size(size: f32) { let action = format!("set_font_size:{size}"); crate::terminal::broadcast_binding_action(&action); @@ -5703,18 +6606,135 @@ fn should_emit_desktop_notification( desktop_notifications_enabled && (!window_active || !workspace_is_active || !source_focused) } +fn should_show_workspace_unread_marker(workspace_is_active: bool, source_focused: bool) -> bool { + !workspace_is_active || !source_focused +} + +fn normalized_notification_kind_hint(raw: &str) -> Option<NotificationVisualKind> { + match raw.trim().to_ascii_lowercase().as_str() { + "attention" | "needs_attention" | "needs-attention" | "input" | "waiting" | "warning" + | "error" => Some(NotificationVisualKind::Attention), + "finished" | "finish" | "complete" | "completed" | "done" | "success" | "succeeded" => { + Some(NotificationVisualKind::Finished) + } + _ => None, + } +} + +fn text_suggests_finished_task(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .any(|word| { + matches!( + word, + "finished" | "complete" | "completed" | "succeeded" | "success" | "done" + ) + }) +} + +fn notification_visual_kind( + kind_hint: Option<&str>, + title: &str, + body: &str, +) -> NotificationVisualKind { + if let Some(kind) = kind_hint.and_then(normalized_notification_kind_hint) { + return kind; + } + + let combined = format!("{} {}", title.trim(), body.trim()); + if text_suggests_finished_task(&combined) { + NotificationVisualKind::Finished + } else { + NotificationVisualKind::Attention + } +} + +fn clear_workspace_notification_visuals(workspace: &Workspace) { + workspace.notify_dot.remove_css_class("limux-notify-dot"); + workspace + .notify_dot + .remove_css_class("limux-notify-dot-attention"); + workspace + .notify_dot + .remove_css_class("limux-notify-dot-finished"); + workspace + .notify_dot + .add_css_class("limux-notify-dot-hidden"); + + workspace + .notify_label + .remove_css_class("limux-notify-msg-unread"); + workspace + .notify_label + .remove_css_class("limux-notify-msg-attention"); + workspace + .notify_label + .remove_css_class("limux-notify-msg-finished"); + workspace.notify_label.add_css_class("limux-notify-msg"); + workspace.notify_label.set_visible(false); + + if let Some(row_box) = workspace.sidebar_row.child() { + row_box.remove_css_class("limux-sidebar-row-unread"); + row_box.remove_css_class("limux-sidebar-row-attention"); + row_box.remove_css_class("limux-sidebar-row-finished"); + } +} + +fn apply_workspace_notification_visuals( + workspace: &Workspace, + message: &str, + kind: NotificationVisualKind, +) { + clear_workspace_notification_visuals(workspace); + workspace + .notify_dot + .remove_css_class("limux-notify-dot-hidden"); + workspace.notify_dot.add_css_class("limux-notify-dot"); + workspace.notify_dot.add_css_class(kind.sidebar_dot_class()); + + workspace.notify_label.set_label(message); + workspace.notify_label.remove_css_class("limux-notify-msg"); + workspace + .notify_label + .add_css_class("limux-notify-msg-unread"); + workspace + .notify_label + .add_css_class(kind.sidebar_message_class()); + workspace.notify_label.set_visible(true); + + if let Some(row_box) = workspace.sidebar_row.child() { + row_box.add_css_class("limux-sidebar-row-unread"); + row_box.add_css_class(kind.sidebar_row_class()); + } +} + fn mark_workspace_unread( state: &State, ws_id: &str, source_focused: bool, target: DesktopNotificationTarget, ) -> Option<DesktopNotificationRequest> { + record_notification_for_workspace( + state, + ws_id, + NotificationRecordDraft { + title: "Process needs attention", + subtitle: "", + body: "", + message: "Process needs attention", + source_focused, + target: target.clone(), + kind: NotificationVisualKind::Attention, + }, + ); mark_workspace_unread_with_message( state, ws_id, "Process needs attention", source_focused, target, + NotificationVisualKind::Attention, ) } @@ -5735,6 +6755,7 @@ fn mark_workspace_unread_with_message( message: &str, source_focused: bool, target: DesktopNotificationTarget, + kind: NotificationVisualKind, ) -> Option<DesktopNotificationRequest> { let mut s = state.borrow_mut(); let active_idx = s.active_idx; @@ -5760,18 +6781,9 @@ fn mark_workspace_unread_with_message( target: target.clone(), }); - if idx != active_idx { + if should_show_workspace_unread_marker(workspace_is_active, source_focused) { ws.unread = true; - ws.notify_dot.remove_css_class("limux-notify-dot-hidden"); - ws.notify_dot.add_css_class("limux-notify-dot"); - ws.notify_label.set_label(message); - ws.notify_label.remove_css_class("limux-notify-msg"); - ws.notify_label.add_css_class("limux-notify-msg-unread"); - ws.notify_label.set_visible(true); - // Add glow pulse to the sidebar row box - if let Some(row_box) = ws.sidebar_row.child() { - row_box.add_css_class("limux-sidebar-row-unread"); - } + apply_workspace_notification_visuals(ws, message, kind); } return desktop_request; @@ -5877,15 +6889,19 @@ mod tests { desktop_notification_activation_token_from_signal, desktop_notification_closed_id_from_signal, desktop_notification_id_from_response, directional_neighbor_score, favorites_prefix_len, font_size_after_delta, - ghostty_prefers_dark, gtk_system_prefers_dark_from_raw, next_active_workspace_index, - pane_create_split_placement, queue_session_save_request, resolve_pane_create_source_id, - resolved_system_prefers_dark, sanitize_background_opacity, + ghostty_prefers_dark, git_branch_for_path, git_branch_from_head, + gtk_system_prefers_dark_from_raw, next_active_workspace_index, notification_detail_text, + notification_visual_kind, pane_create_split_placement, parse_proc_fd_socket_inode, + parse_proc_net_listening_ports, path_is_within, queue_session_save_request, + resolve_pane_create_source_id, resolved_system_prefers_dark, sanitize_background_opacity, shortcut_allowed_while_browser_find_active, shortcut_blocked_by_editable, shortcut_command_from_key_event, shortcut_dispatch_propagation, - should_emit_desktop_notification, tab_drag_workspace_seed, use_opaque_window_background, + should_emit_desktop_notification, should_show_workspace_unread_marker, + sidebar_ports_summary, tab_drag_workspace_seed, use_opaque_window_background, validate_workspace_folder_input_with_dirs, workspace_drop_layout_path, - workspace_folder_path_from_input, workspace_notification_message, Direction, - EditableCaptureContext, NeighborScore, PaneBounds, PaneCreateDirection, + workspace_folder_path_from_input, workspace_notification_message, + DesktopNotificationTarget, Direction, EditableCaptureContext, NeighborScore, + NotificationRecord, NotificationVisualKind, PaneBounds, PaneCreateDirection, PaneCreateTargetError, PortalColorSchemePreference, SessionSaveAccess, SessionSaveRequest, WorkspaceSeedSource, BASE_CSS, HOST_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASS, WORKSPACE_RENAME_ENTRY_CSS_CLASSES, @@ -6120,20 +7136,120 @@ mod tests { #[test] fn font_size_after_delta_clamps_to_supported_range() { - assert_eq!(font_size_after_delta(Some(1.0), 12.0, -5.0), 1.0); + assert_eq!(font_size_after_delta(Some(8.0), 12.0, -5.0), 8.0); assert_eq!(font_size_after_delta(Some(255.0), 12.0, 5.0), 255.0); } #[test] - fn base_css_defines_theme_aware_host_entry_styles() { - assert!(BASE_CSS.contains(":root")); - assert!(BASE_CSS.contains("@media (prefers-color-scheme: dark)")); + fn base_css_defines_gtk_compatible_host_entry_styles() { + assert!(!BASE_CSS.contains(":root")); + assert!(!BASE_CSS.contains("@media")); + assert!(!BASE_CSS.contains("var(")); assert!(BASE_CSS.contains(".limux-host-entry")); assert!(BASE_CSS.contains(".limux-host-entry text")); assert!(BASE_CSS.contains(".limux-host-entry text placeholder")); + assert!(BASE_CSS.contains("alpha(@window_bg_color, 0.98)")); assert!(BASE_CSS.contains("caret-color: currentColor;")); } + #[test] + fn git_branch_from_head_formats_refs_and_detached_heads() { + assert_eq!( + git_branch_from_head("ref: refs/heads/main\n").as_deref(), + Some("main") + ); + assert_eq!( + git_branch_from_head("ref: refs/heads/feature/sidebar-polish\n").as_deref(), + Some("feature/sidebar-polish") + ); + assert_eq!( + git_branch_from_head("0123456789abcdef\n").as_deref(), + Some("@0123456") + ); + assert_eq!(git_branch_from_head("not a head"), None); + } + + #[test] + fn git_branch_for_path_walks_parent_direct_git_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = dir.path().join("repo"); + let nested = repo.join("src/bin"); + std::fs::create_dir_all(repo.join(".git")).expect("git dir"); + std::fs::create_dir_all(&nested).expect("nested dir"); + std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/ui-polish\n").expect("head"); + + assert_eq!( + git_branch_for_path(nested.to_str().expect("utf8 path")).as_deref(), + Some("ui-polish") + ); + } + + #[test] + fn git_branch_for_path_handles_worktree_gitdir_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let worktree = dir.path().join("worktree"); + let gitdir = dir.path().join("common/worktrees/worktree"); + std::fs::create_dir_all(&worktree).expect("worktree"); + std::fs::create_dir_all(&gitdir).expect("gitdir"); + std::fs::write( + worktree.join(".git"), + "gitdir: ../common/worktrees/worktree\n", + ) + .expect("git file"); + std::fs::write(gitdir.join("HEAD"), "ref: refs/heads/cmux-sidebar\n").expect("head"); + + assert_eq!( + git_branch_for_path(worktree.to_str().expect("utf8 path")).as_deref(), + Some("cmux-sidebar") + ); + } + + #[test] + fn proc_net_parser_extracts_listening_socket_ports() { + let raw = "\ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 12345 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:1F90 00000000:0000 01 00000000:00000000 00:00000000 00000000 1000 0 67890 1 0000000000000000 100 0 0 10 0 + 2: 00000000000000000000000000000000:C001 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 24680 1 0000000000000000 100 0 0 10 0 +"; + + assert_eq!( + parse_proc_net_listening_ports(raw), + vec![(12345, 3000), (24680, 49153)] + ); + } + + #[test] + fn proc_fd_socket_inode_parser_requires_socket_target() { + assert_eq!(parse_proc_fd_socket_inode("socket:[12345]"), Some(12345)); + assert_eq!(parse_proc_fd_socket_inode("anon_inode:[eventfd]"), None); + assert_eq!(parse_proc_fd_socket_inode("/tmp/file"), None); + } + + #[test] + fn sidebar_ports_summary_stays_compact() { + assert_eq!(sidebar_ports_summary(&[]), ""); + assert_eq!(sidebar_ports_summary(&[3000]), "localhost:3000"); + assert_eq!( + sidebar_ports_summary(&[3000, 5173, 8080]), + "localhost:3000 +2" + ); + } + + #[test] + fn path_is_within_accepts_nested_workspace_paths() { + let dir = tempfile::tempdir().expect("tempdir"); + let workspace = dir.path().join("project"); + let nested = workspace.join("server"); + let outside = dir.path().join("other"); + std::fs::create_dir_all(&nested).expect("nested"); + std::fs::create_dir_all(&outside).expect("outside"); + + assert!(path_is_within(&nested, &workspace)); + assert!(path_is_within(&workspace, &workspace)); + assert!(!path_is_within(&outside, &workspace)); + } + #[test] fn workspace_rename_entry_uses_shared_host_entry_class() { assert_eq!( @@ -6143,6 +7259,115 @@ mod tests { assert!(BASE_CSS.contains(".limux-ws-rename-entry")); } + #[test] + fn sidebar_workspace_rows_remove_theme_horizontal_insets() { + let row_rule = BASE_CSS + .split(".limux-sidebar .navigation-sidebar > row {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("sidebar navigation row CSS rule"); + assert!(row_rule.contains("padding-left: 0;")); + assert!(row_rule.contains("padding-right: 0;")); + assert!(row_rule.contains("margin-left: 0;")); + assert!(row_rule.contains("margin-right: 0;")); + + let row_box_rule = BASE_CSS + .split(".limux-sidebar-row-box {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("sidebar row box CSS rule"); + assert!(row_box_rule.contains("margin: 2px 0;")); + } + + #[test] + fn workspace_unread_marker_does_not_change_row_width() { + let unread_rule = BASE_CSS + .split(".limux-sidebar-row-unread {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("workspace unread CSS rule"); + assert!(unread_rule.contains("box-shadow: inset 3px 0 0 0 @accent_bg_color;")); + assert!(!unread_rule.contains("border-left")); + assert!(!unread_rule.contains("margin-left")); + assert!(!unread_rule.contains("margin-right")); + } + + #[test] + fn sidebar_notification_css_distinguishes_attention_and_finished_states() { + assert!(BASE_CSS.contains(".limux-ws-meta-row")); + assert!(BASE_CSS.contains(".limux-ws-branch")); + assert!(BASE_CSS.contains(".limux-ws-ports")); + assert!(BASE_CSS.contains(".limux-sidebar-row-attention")); + assert!(BASE_CSS.contains(".limux-sidebar-row-finished")); + assert!(BASE_CSS.contains(".limux-notify-dot-attention")); + assert!(BASE_CSS.contains(".limux-notify-dot-finished")); + assert!(BASE_CSS.contains(".limux-notify-msg-attention")); + assert!(BASE_CSS.contains(".limux-notify-msg-finished")); + } + + #[test] + fn notification_panel_css_covers_history_states() { + assert!(BASE_CSS.contains(".limux-notification-button")); + assert!(BASE_CSS.contains(".limux-notification-button-unread")); + assert!(BASE_CSS.contains(".limux-notification-panel")); + assert!(BASE_CSS.contains(".limux-notification-row-attention")); + assert!(BASE_CSS.contains(".limux-notification-row-finished")); + assert!(BASE_CSS.contains(".limux-notification-row-unread")); + assert!(BASE_CSS.contains(".limux-notification-status-attention")); + assert!(BASE_CSS.contains(".limux-notification-status-finished")); + } + + #[test] + fn notification_detail_text_avoids_duplicate_sidebar_message() { + let base = NotificationRecord { + id: 1, + target: DesktopNotificationTarget { + workspace_id: "workspace-a".to_string(), + pane_id: Some(7), + tab_id: Some("tab-a".to_string()), + }, + workspace_name: "codex".to_string(), + title: "Codex".to_string(), + subtitle: String::new(), + body: "Turn complete".to_string(), + message: "Turn complete".to_string(), + kind: NotificationVisualKind::Finished, + unread: true, + }; + + assert_eq!(notification_detail_text(&base), None); + + let with_subtitle = NotificationRecord { + subtitle: "session-a".to_string(), + message: "Codex - Turn complete".to_string(), + ..base + }; + assert_eq!( + notification_detail_text(&with_subtitle).as_deref(), + Some("session-a - Turn complete") + ); + } + + #[test] + fn notification_visual_kind_uses_explicit_kind_and_finished_copy() { + assert_eq!( + notification_visual_kind(Some("finished"), "Process needs attention", ""), + NotificationVisualKind::Finished + ); + assert_eq!( + notification_visual_kind(Some("attention"), "Task finished", ""), + NotificationVisualKind::Attention + ); + assert_eq!( + notification_visual_kind(None, "Process needs attention", "Codex finished"), + NotificationVisualKind::Finished + ); + assert_eq!( + notification_visual_kind(None, "Process needs attention", "agent is unfinished"), + NotificationVisualKind::Attention + ); + } + #[test] fn desktop_notification_actions_include_default_open_action() { assert_eq!( @@ -6334,6 +7559,14 @@ mod tests { assert!(!should_emit_desktop_notification(true, true, true, true)); } + #[test] + fn workspace_unread_marker_shows_for_background_sources() { + assert!(should_show_workspace_unread_marker(false, false)); + assert!(should_show_workspace_unread_marker(false, true)); + assert!(should_show_workspace_unread_marker(true, false)); + assert!(!should_show_workspace_unread_marker(true, true)); + } + #[test] fn shortcut_command_from_key_event_uses_default_registry_bindings() { let shortcuts = default_shortcuts(); diff --git a/scripts/xvfb-smoke-test.sh b/scripts/xvfb-smoke-test.sh index ba5cd312..5de73df1 100755 --- a/scripts/xvfb-smoke-test.sh +++ b/scripts/xvfb-smoke-test.sh @@ -117,13 +117,16 @@ SMOKE_SESSION echo echo "== stage 1: boot limux host under xvfb-run ==" -# Under Xvfb there is no GPU, so Mesa would fall back to llvmpipe, which -# has historically crashed on Ghostty's shader variants. Force softpipe -# (slower but stable), and pin GL version to avoid newer-feature probes. +# Under Xvfb there is no GPU. Force Mesa's software renderer and pin a GL/GLSL +# level that satisfies embedded Ghostty's OpenGL 4.3 renderer. export LIBGL_ALWAYS_SOFTWARE=1 -export GALLIUM_DRIVER=softpipe -export LP_NUM_THREADS=1 -export MESA_GL_VERSION_OVERRIDE="${MESA_GL_VERSION_OVERRIDE:-3.3}" +export GALLIUM_DRIVER="${GALLIUM_DRIVER:-llvmpipe}" +export LP_NUM_THREADS="${LP_NUM_THREADS:-1}" +export MESA_GL_VERSION_OVERRIDE="${MESA_GL_VERSION_OVERRIDE:-4.3}" +export MESA_GLSL_VERSION_OVERRIDE="${MESA_GLSL_VERSION_OVERRIDE:-430}" +export GDK_BACKEND="${GDK_BACKEND:-x11}" +export XDG_SESSION_TYPE=x11 +unset WAYLAND_DISPLAY xvfb-run -a -s "-screen 0 1280x800x24 +extension GLX +render" \ "$LIMUX_HOST" >"$LOG_DIR/host.stdout" 2>"$LOG_DIR/host.stderr" & HOST_PID=$! @@ -167,55 +170,86 @@ done [ -S "$SOCKET" ] || { echo "FAIL: socket $SOCKET never appeared"; exit 1; } +# The control socket is created before GTK has necessarily finished restoring +# the active workspace and first surface. Wait for the same active-context +# resolution that agent-team uses before exercising the live bridge. +for i in $(seq 1 60); do + if "$LIMUX_CLI" --json identify >"$LOG_DIR/ready.json" 2>"$LOG_DIR/ready.err"; then + echo "active workspace ready after ${i}*500ms" + break + fi + if ! kill -0 "$HOST_PID" 2>/dev/null; then + echo "FAIL: host process died before active workspace was ready" + exit 1 + fi + sleep 0.5 +done + +"$LIMUX_CLI" --json identify >"$LOG_DIR/ready.json" 2>"$LOG_DIR/ready.err" \ + || { echo "FAIL: active workspace never became ready"; cat "$LOG_DIR/ready.err"; exit 1; } + # --- 5. Stage 2: live agent-team ------------------------------------------ echo echo "== stage 2: agent-team against live host (--no-launch) ==" # --no-launch keeps the workspace commands from actually spawning codex/ # claude binaries (which may not be installed in CI); the bridge + AGENTS.md -# + allow_name=true path are still fully exercised. -"$LIMUX_CLI" --id-format both agent-team \ +# + exact-surface targeting path are still fully exercised. +"$LIMUX_CLI" --id-format both --json agent-team \ --agents codex,claude \ --cwd "$DEMO_DIR" \ --no-launch \ - 2>&1 | tee "$LOG_DIR/stage2.txt" + 2>&1 | tee "$LOG_DIR/stage2.json" -grep -q "peers=\[codex, claude\]" "$LOG_DIR/stage2.txt" \ +grep -q '"agent":"codex"' "$LOG_DIR/stage2.json" \ + || { echo "FAIL: live agent-team did not create codex peer"; exit 1; } +grep -q '"agent":"claude"' "$LOG_DIR/stage2.json" \ || { echo "FAIL: live agent-team did not create peers"; exit 1; } [ -f "$DEMO_DIR/AGENTS.md" ] \ || { echo "FAIL: AGENTS.md not written to $DEMO_DIR"; exit 1; } +TEAM_WORKSPACE_ID="$(sed -n 's/.*"workspace_id":"\([^"]*\)".*/\1/p' "$LOG_DIR/stage2.json" | head -1)" +TEAM_WORKSPACE_NAME="$(sed -n 's/.*"workspace_name":"\([^"]*\)".*/\1/p' "$LOG_DIR/stage2.json" | head -1)" +CODEX_SURFACE="$(sed -n 's/.*{"agent":"codex"[^}]*"surface_id":"\([^"]*\)".*/\1/p' "$LOG_DIR/stage2.json" | head -1)" +CLAUDE_SURFACE="$(sed -n 's/.*{"agent":"claude"[^}]*"surface_id":"\([^"]*\)".*/\1/p' "$LOG_DIR/stage2.json" | head -1)" + +[ -n "$TEAM_WORKSPACE_ID" ] || { echo "FAIL: stage 2 response missing workspace_id"; exit 1; } +[ -n "$TEAM_WORKSPACE_NAME" ] || { echo "FAIL: stage 2 response missing workspace_name"; exit 1; } +[ -n "$CODEX_SURFACE" ] || { echo "FAIL: stage 2 response missing codex surface"; exit 1; } +[ -n "$CLAUDE_SURFACE" ] || { echo "FAIL: stage 2 response missing claude surface"; exit 1; } + # Assert the runtime AGENTS.md has the protocol envelope + both peers. grep -q "<agent-msg" "$DEMO_DIR/AGENTS.md" || { echo "FAIL: AGENTS.md missing <agent-msg>"; exit 1; } grep -q "\bcodex\b" "$DEMO_DIR/AGENTS.md" || { echo "FAIL: AGENTS.md missing codex peer"; exit 1; } grep -q "\bclaude\b" "$DEMO_DIR/AGENTS.md" || { echo "FAIL: AGENTS.md missing claude peer"; exit 1; } -echo "stage 2: OK (AGENTS.md + 2 workspaces + allow_name bridge path)" +echo "stage 2: OK (AGENTS.md + peer panes)" -# --- 6. Stage 3: list-workspaces sanity ----------------------------------- +# --- 6. Stage 3: peer surface sanity -------------------------------------- echo -echo "== stage 3: list-workspaces sees both peers ==" -"$LIMUX_CLI" list-workspaces 2>&1 | tee "$LOG_DIR/stage3.txt" -grep -q codex "$LOG_DIR/stage3.txt" || { echo "FAIL: list-workspaces missing codex"; exit 1; } -grep -q claude "$LOG_DIR/stage3.txt" || { echo "FAIL: list-workspaces missing claude"; exit 1; } +echo "== stage 3: surface.list sees both peer panes ==" +"$LIMUX_CLI" --json list-panels --workspace "$TEAM_WORKSPACE_NAME" 2>&1 | tee "$LOG_DIR/stage3.json" +grep -Fq "$CODEX_SURFACE" "$LOG_DIR/stage3.json" \ + || { echo "FAIL: surface.list missing codex surface $CODEX_SURFACE"; exit 1; } +grep -Fq "$CLAUDE_SURFACE" "$LOG_DIR/stage3.json" \ + || { echo "FAIL: surface.list missing claude surface $CLAUDE_SURFACE"; exit 1; } echo "stage 3: OK" -# --- 7. Stage 4: by-name send (the phase-5 allow_name=true unlock) -------- -# This is the single most important assertion in the whole harness — -# it proves that `limux send --workspace <name>` resolves to the right -# workspace via the bridge. Without allow_name=true this errors out. +# --- 7. Stage 4: by-name workspace + exact-surface send ------------------- echo -echo "== stage 4: surface.send_text by workspace name ==" +echo "== stage 4: surface.send_text by workspace name and peer surface ==" ENVELOPE=$'<agent-msg from="codex" to="claude" id="smoke-1" ts="2026-04-19T23:59:00Z"><request>smoke test ping</request></agent-msg>\n' -if "$LIMUX_CLI" send --workspace claude "$ENVELOPE" 2>&1 | tee "$LOG_DIR/stage4.txt"; then - echo "stage 4: OK (by-name send accepted)" +if "$LIMUX_CLI" send --workspace "$TEAM_WORKSPACE_NAME" --surface "$CLAUDE_SURFACE" "$ENVELOPE" \ + 2>&1 | tee "$LOG_DIR/stage4.txt"; then + echo "stage 4: OK (workspace-name + surface send accepted)" else - echo "FAIL: by-name send to 'claude' failed — allow_name=true may be regressed" + echo "FAIL: send to claude surface failed" exit 1 fi # --- 8. Stage 5: by-name notify ------------------------------------------- echo echo "== stage 5: notification.create by workspace name ==" -if "$LIMUX_CLI" notify --workspace claude --subtitle "smoke" --body "all good" "Smoke test" \ +if "$LIMUX_CLI" notify --workspace "$TEAM_WORKSPACE_NAME" --kind attention \ + --subtitle "smoke" --body "all good" "Smoke test" \ 2>&1 | tee "$LOG_DIR/stage5.txt"; then echo "stage 5: OK (by-name notify accepted)" else @@ -230,8 +264,9 @@ SELF_SPLIT_PROOF="$DEMO_DIR/self-split-proof" SELF_SPLIT_ENV="$DEMO_DIR/self-split-env" SELF_SPLIT_CMD="printf split-ok > '$SELF_SPLIT_PROOF'; printf '%s\n%s\n%s\n' \"\$LIMUX_WORKSPACE_ID\" \"\$LIMUX_PANE_ID\" \"\$LIMUX_SURFACE_ID\" > '$SELF_SPLIT_ENV'" -"$LIMUX_CLI" --json new-pane \ - --workspace claude \ +"$LIMUX_CLI" --id-format both --json new-pane \ + --workspace "$TEAM_WORKSPACE_NAME" \ + --surface "$CLAUDE_SURFACE" \ --direction right \ --command "$SELF_SPLIT_CMD" \ 2>&1 | tee "$LOG_DIR/stage6.json" @@ -277,7 +312,7 @@ echo "stage 6: OK (self-split command ran with fresh LIMUX_* env)" echo echo "== stage 7: claude-hook event translation ==" if echo '{"hook_event_name":"Notification","message":"hello from smoke"}' \ - | LIMUX_WORKSPACE_ID="" "$LIMUX_CLI" claude-hook 2>&1 \ + | LIMUX_WORKSPACE_ID="$TEAM_WORKSPACE_ID" LIMUX_SURFACE_ID="$CLAUDE_SURFACE" "$LIMUX_CLI" claude-hook 2>&1 \ | tee "$LOG_DIR/stage7.txt"; then echo "stage 7: OK (claude-hook accepted JSON on stdin)" else From 605fc7748efdf99b871e967d80dd0a27bd326b8c Mon Sep 17 00:00:00 2001 From: Pavlo Grubyi <batcavepost@gmail.com> Date: Sun, 7 Jun 2026 20:16:35 +0100 Subject: [PATCH 2/4] chore: add local install helper --- .gitignore | 1 + README.md | 12 ++ scripts/install-local-build.sh | 228 +++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100755 scripts/install-local-build.sh diff --git a/.gitignore b/.gitignore index daa6fcdf..8c5bd35b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ zig/.zig-cache/ zig/zig-out/ +ghostty.incomplete.*/ dist/ *.tar.gz .worktrees/ diff --git a/README.md b/README.md index e017e292..fad4d086 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,18 @@ Run the canonical local quality gate before committing: Repository maintainability rules live in [`docs/maintainability.md`](docs/maintainability.md). +For user-visible CLI or GTK host changes, install the active local build before +validating it through the desktop entry or the `limux` command on `PATH`: + +```bash +./scripts/install-local-build.sh +``` + +The script builds the CLI and GTK host, installs them under +`$LIMUX_LOCAL_PREFIX` or `~/.local`, copies a matching `libghostty.so`, and +verifies that the active `limux` entrypoint resolves to the fresh local build. +Restart any already-running Limux GUI after installing. + ## Agent integrations Limux ships first-class hooks for coding agents (Codex, Claude Code, Gemini diff --git a/scripts/install-local-build.sh b/scripts/install-local-build.sh new file mode 100755 index 00000000..e5d296fb --- /dev/null +++ b/scripts/install-local-build.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PREFIX="${LIMUX_LOCAL_PREFIX:-$HOME/.local}" +PROFILE="${LIMUX_LOCAL_PROFILE:-release}" +RUST_TOOLCHAIN="${LIMUX_RUST_TOOLCHAIN:-}" + +case "$PROFILE" in + debug) + CARGO_FLAGS=() + TARGET_DIR="$ROOT_DIR/target/debug" + ;; + release) + CARGO_FLAGS=(--release) + TARGET_DIR="$ROOT_DIR/target/release" + ;; + *) + echo "ERROR: LIMUX_LOCAL_PROFILE must be debug or release, got '$PROFILE'." >&2 + exit 1 + ;; +esac + +CLI_SRC="$TARGET_DIR/limux-cli" +HOST_SRC="$TARGET_DIR/limux" +CLI_DEST="$PREFIX/bin/limux" +HOST_DIR="$PREFIX/libexec/limux" +HOST_WRAPPER="$HOST_DIR/limux-host" +HOST_BIN_DEST="$HOST_DIR/limux-host.bin" +LIB_DEST="$PREFIX/lib/limux/libghostty.so" +DESKTOP_SRC="$ROOT_DIR/rust/limux-host-linux/dev.limux.linux.desktop" +DESKTOP_DEST="$PREFIX/share/applications/dev.limux.linux.desktop" +BUILD_INFO_DEST="$PREFIX/share/limux/local-build.txt" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +sha256_file() { + sha256sum "$1" | awk '{print $1}' +} + +resolve_first_file() { + local candidate + + for candidate in "$@"; do + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +resolve_first_dir() { + local candidate + + for candidate in "$@"; do + if [ -d "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +copy_dir_contents() { + local src="$1" + local dest="$2" + + mkdir -p "$dest" + cp -R "$src"/. "$dest"/ +} + +install_file_atomic() { + local src="$1" + local dest="$2" + local mode="$3" + local dir + local tmp + + dir="$(dirname "$dest")" + mkdir -p "$dir" + tmp="$(mktemp "$dir/.install.XXXXXX")" + cp "$src" "$tmp" + chmod "$mode" "$tmp" + mv -f "$tmp" "$dest" +} + +install_desktop_file() { + mkdir -p "$(dirname "$DESKTOP_DEST")" + sed \ + -e "s|^Exec=.*|Exec=${CLI_DEST}|" \ + -e "s|^TryExec=.*|TryExec=${CLI_DEST}|" \ + "$DESKTOP_SRC" > "$DESKTOP_DEST" + chmod 644 "$DESKTOP_DEST" +} + +write_host_wrapper() { + mkdir -p "$HOST_DIR" + local tmp + tmp="$(mktemp "$HOST_DIR/.limux-host.XXXXXX")" + cat > "$tmp" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PREFIX="$(cd "$HERE/../.." && pwd)" +export LD_LIBRARY_PATH="$PREFIX/lib/limux${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +exec "$HERE/limux-host.bin" "$@" +EOF + chmod 755 "$tmp" + mv -f "$tmp" "$HOST_WRAPPER" +} + +need_cmd awk +need_cmd cargo +need_cmd cp +need_cmd ldd +need_cmd mktemp +need_cmd sha256sum + +cd "$ROOT_DIR" + +CARGO_CMD=(cargo) +if [ -n "$RUST_TOOLCHAIN" ]; then + CARGO_CMD=(cargo "+$RUST_TOOLCHAIN") +elif cargo +1.92 --version >/dev/null 2>&1; then + CARGO_CMD=(cargo +1.92) +fi + +echo "Building Limux local $PROFILE binaries..." +"${CARGO_CMD[@]}" build "${CARGO_FLAGS[@]}" -p limux-cli --bin limux-cli +"${CARGO_CMD[@]}" build "${CARGO_FLAGS[@]}" -p limux-host-linux --bin limux + +[ -x "$CLI_SRC" ] || fail "CLI binary not found at $CLI_SRC" +[ -x "$HOST_SRC" ] || fail "host binary not found at $HOST_SRC" + +GHOSTTY_LIB_SRC="$(resolve_first_file \ + "$ROOT_DIR/ghostty/zig-out/lib/libghostty.so" \ + /usr/local/lib/limux/libghostty.so \ + /usr/lib/limux/libghostty.so)" \ + || fail "libghostty.so not found; build Ghostty or install Limux first" + +install_file_atomic "$CLI_SRC" "$CLI_DEST" 755 +install_file_atomic "$HOST_SRC" "$HOST_BIN_DEST" 755 +write_host_wrapper +install_file_atomic "$GHOSTTY_LIB_SRC" "$LIB_DEST" 644 + +if GHOSTTY_RESOURCES_SRC="$(resolve_first_dir \ + "$ROOT_DIR/ghostty/zig-out/share/ghostty" \ + /usr/local/share/limux/ghostty \ + /usr/share/limux/ghostty \ + /usr/local/share/ghostty \ + /usr/share/ghostty)"; then + copy_dir_contents "$GHOSTTY_RESOURCES_SRC" "$PREFIX/share/limux/ghostty" +else + echo "WARNING: Ghostty resources were not found; existing runtime fallbacks will be used." >&2 +fi + +if GHOSTTY_TERMINFO_SRC="$(resolve_first_dir \ + "$ROOT_DIR/ghostty/zig-out/share/terminfo" \ + /usr/local/share/limux/terminfo \ + /usr/share/limux/terminfo \ + /usr/local/share/terminfo \ + /usr/share/terminfo)"; then + copy_dir_contents "$GHOSTTY_TERMINFO_SRC" "$PREFIX/share/limux/terminfo" +else + echo "WARNING: Ghostty terminfo was not found; existing runtime fallbacks will be used." >&2 +fi + +if [ -f "$DESKTOP_SRC" ]; then + install_desktop_file +fi + +mkdir -p "$(dirname "$BUILD_INFO_DEST")" +{ + printf 'profile=%s\n' "$PROFILE" + printf 'installed_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf 'git_head=%s\n' "$(git rev-parse --short=12 HEAD 2>/dev/null || printf unknown)" + printf 'git_dirty=%s\n' "$(test -z "$(git status --short 2>/dev/null)" && printf false || printf true)" + printf 'cli_sha256=%s\n' "$(sha256_file "$CLI_DEST")" + printf 'host_sha256=%s\n' "$(sha256_file "$HOST_BIN_DEST")" + printf 'libghostty_sha256=%s\n' "$(sha256_file "$LIB_DEST")" +} > "$BUILD_INFO_DEST" + +ACTIVE_LIMUX="$(command -v limux 2>/dev/null || true)" +[ -n "$ACTIVE_LIMUX" ] || fail "limux is not on PATH after installing $CLI_DEST" + +if [ "$(readlink -f "$ACTIVE_LIMUX")" != "$(readlink -f "$CLI_DEST")" ]; then + fail "active limux is $ACTIVE_LIMUX, expected $CLI_DEST. Put $PREFIX/bin before older Limux installs on PATH." +fi + +"$CLI_DEST" --help 2>&1 | grep -q "limux CLI" \ + || fail "$CLI_DEST is not the Limux CLI entrypoint" + +[ "$(sha256_file "$CLI_SRC")" = "$(sha256_file "$CLI_DEST")" ] \ + || fail "installed CLI hash does not match $CLI_SRC" +[ "$(sha256_file "$HOST_SRC")" = "$(sha256_file "$HOST_BIN_DEST")" ] \ + || fail "installed host hash does not match $HOST_SRC" + +RESOLVED_LIB="$( + LD_LIBRARY_PATH="$PREFIX/lib/limux${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + ldd "$HOST_BIN_DEST" \ + | awk '/libghostty\.so/ {print $3; exit}' +)" +[ -n "$RESOLVED_LIB" ] || fail "host does not resolve libghostty.so" + +if [ "$(readlink -f "$RESOLVED_LIB")" != "$(readlink -f "$LIB_DEST")" ]; then + fail "host resolves libghostty.so to $RESOLVED_LIB, expected $LIB_DEST" +fi + +"$HOST_WRAPPER" --version >/dev/null \ + || fail "installed host wrapper failed to execute" + +echo "Installed active local Limux build." +echo " CLI: $CLI_DEST" +echo " Host: $HOST_WRAPPER -> $HOST_BIN_DEST" +echo " Library: $LIB_DEST" +echo " Build info: $BUILD_INFO_DEST" +echo "Restart any already-running Limux GUI to use this host build." From d56dc491ce66a62413376507b17356c44e5cd902 Mon Sep 17 00:00:00 2001 From: Pavlo Grubyi <batcavepost@gmail.com> Date: Mon, 8 Jun 2026 07:31:54 +0100 Subject: [PATCH 3/4] feat: refine cmux-style UI with system colors --- rust/limux-host-linux/src/keybind_editor.rs | 11 +- rust/limux-host-linux/src/pane.rs | 40 +++- rust/limux-host-linux/src/settings_editor.rs | 34 +-- rust/limux-host-linux/src/window.rs | 231 +++++++++++++------ 4 files changed, 220 insertions(+), 96 deletions(-) diff --git a/rust/limux-host-linux/src/keybind_editor.rs b/rust/limux-host-linux/src/keybind_editor.rs index 6b109de4..a48c87c6 100644 --- a/rust/limux-host-linux/src/keybind_editor.rs +++ b/rust/limux-host-linux/src/keybind_editor.rs @@ -50,7 +50,7 @@ pub const KEYBIND_EDITOR_CSS: &str = r#" opacity: 0.7; } .limux-keybind-capture { - min-width: 168px; + min-width: 160px; padding: 8px 12px; } .limux-keybind-capture-listening { @@ -411,7 +411,7 @@ fn validation_error_message(err: &ShortcutConfigError) -> String { mod tests { use super::{ binding_button_label, capture_outcome_for_key_event, validation_error_message, - CaptureOutcome, + CaptureOutcome, KEYBIND_EDITOR_CSS, }; use crate::shortcut_config::{ default_shortcuts, resolve_shortcuts_from_str, ShortcutConfigError, ShortcutId, @@ -456,6 +456,13 @@ mod tests { ); } + #[test] + fn keybind_editor_css_uses_system_accent() { + assert!(KEYBIND_EDITOR_CSS.contains("@accent_bg_color")); + assert!(!KEYBIND_EDITOR_CSS.contains("limux_cmux_accent")); + assert!(!KEYBIND_EDITOR_CSS.contains("rgb(0, 145, 255)")); + } + #[test] fn capture_outcome_keeps_listening_for_modifier_only_press() { assert!(matches!( diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 4f17fa68..63cf115d 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -333,17 +333,17 @@ pub const PANE_CSS: &str = r#" background-color: @window_bg_color; color: @window_fg_color; border-bottom: 1px solid alpha(@window_fg_color, 0.08); - min-height: 30px; + min-height: 28px; padding: 0 2px; } .limux-tab { background: none; border: none; border-radius: 4px 4px 0 0; - padding: 4px 4px 4px 10px; + padding: 4px 4px 4px 8px; color: alpha(@window_fg_color, 0.5); min-height: 0; - font-size: 12px; + font-size: 11px; } .limux-tab:hover { color: alpha(@window_fg_color, 0.72); @@ -358,7 +358,7 @@ pub const PANE_CSS: &str = r#" font-weight: 600; } .limux-tab-status { - font-size: 10px; + font-size: 8px; min-width: 10px; margin-right: 3px; } @@ -393,12 +393,15 @@ pub const PANE_CSS: &str = r#" .limux-pane-action { background: none; border: none; - border-radius: 4px; - padding: 4px 5px; + border-radius: 6px; + padding: 4px; min-height: 0; min-width: 0; color: alpha(@window_fg_color, 0.4); } +.limux-pane-action image { + -gtk-icon-size: 12px; +} .limux-pane-action:hover { background: alpha(@window_fg_color, 0.08); color: alpha(@window_fg_color, 0.8); @@ -424,8 +427,8 @@ pub const PANE_CSS: &str = r#" .limux-split-btn { background: none; border: none; - border-radius: 4px; - padding: 4px 5px; + border-radius: 6px; + padding: 4px; min-height: 0; min-width: 0; } @@ -439,16 +442,24 @@ pub const PANE_CSS: &str = r#" .limux-tab-rename-entry { padding: 1px 4px; min-height: 0; - font-size: 12px; + font-size: 11px; } .limux-browser-url-entry { - min-height: 0; + min-height: 18px; font-size: 12px; } .limux-browser-search-entry { - min-height: 0; + min-height: 18px; font-size: 12px; } +.limux-browser-nav-button { + min-width: 26px; + min-height: 26px; + padding: 7px; +} +.limux-browser-nav-button image { + -gtk-icon-size: 12px; +} .limux-browser, .limux-browser-web-view { min-width: 0; @@ -3457,6 +3468,9 @@ fn create_browser_widget( let back_btn = icon_button("go-previous-symbolic", "Back"); let fwd_btn = icon_button("go-next-symbolic", "Forward"); let reload_btn = icon_button("view-refresh-symbolic", "Reload"); + back_btn.add_css_class("limux-browser-nav-button"); + fwd_btn.add_css_class("limux-browser-nav-button"); + reload_btn.add_css_class("limux-browser-nav-button"); let nav_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4); nav_bar.add_css_class("limux-pane-header"); @@ -3715,7 +3729,9 @@ mod tests { assert!(PANE_CSS.contains(".limux-browser-search-entry")); #[cfg(feature = "webkit")] assert!(PANE_CSS.contains(BROWSER_WEB_VIEW_CSS_CLASS)); - assert!(!PANE_CSS.contains("border: 1px solid rgba(0, 145, 255, 0.5);")); + assert!(PANE_CSS.contains("@accent_bg_color")); + assert!(!PANE_CSS.contains("limux_cmux_accent")); + assert!(!PANE_CSS.contains("rgb(0, 145, 255)")); } #[test] diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs index ecc14d44..8c0f9790 100644 --- a/rust/limux-host-linux/src/settings_editor.rs +++ b/rust/limux-host-linux/src/settings_editor.rs @@ -44,28 +44,28 @@ pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ label: "Sidebar workspace name", subtitle: "Workspace names in the left sidebar", selector: ".limux-ws-name", - default_size: 15.0, + default_size: 12.5, }, UiFontDescriptor { id: "sidebar_favorite_star", label: "Sidebar favorite star", subtitle: "Pinned workspace star icon in sidebar rows", selector: ".limux-ws-star-btn", - default_size: 22.0, + default_size: 9.0, }, UiFontDescriptor { id: "sidebar_notification_dot", label: "Sidebar notification dot", subtitle: "Unread notification marker in workspace rows", selector: ".limux-notify-dot, .limux-notify-dot-hidden", - default_size: 10.0, + default_size: 9.0, }, UiFontDescriptor { id: "sidebar_notification_message", label: "Sidebar notification message", subtitle: "Notification preview text below workspace names", selector: ".limux-notify-msg, .limux-notify-msg-unread", - default_size: 11.0, + default_size: 10.0, }, UiFontDescriptor { id: "sidebar_section_title", @@ -79,35 +79,35 @@ pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ label: "Sidebar workspace path", subtitle: "Folder path text below workspace names", selector: ".limux-ws-path", - default_size: 12.0, + default_size: 10.0, }, UiFontDescriptor { id: "sidebar_git_branch", label: "Sidebar git branch", subtitle: "Git branch pill in workspace rows", selector: ".limux-ws-branch", - default_size: 11.0, + default_size: 10.0, }, UiFontDescriptor { id: "sidebar_ports", label: "Sidebar ports", subtitle: "Localhost port pill in workspace rows", selector: ".limux-ws-ports", - default_size: 11.0, + default_size: 10.0, }, UiFontDescriptor { id: "pane_tab_title", label: "Pane tab title", subtitle: "Terminal and browser tab labels in pane headers", selector: ".limux-tab", - default_size: 12.0, + default_size: 11.0, }, UiFontDescriptor { id: "pane_tab_status_icon", label: "Pane tab status icon", subtitle: "Attention and finished marker shown inside pane tabs", selector: ".limux-tab-status", - default_size: 10.0, + default_size: 8.0, }, UiFontDescriptor { id: "pane_pin_icon", @@ -121,14 +121,14 @@ pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ label: "Pane tab rename entry", subtitle: "Inline text field used while renaming a tab", selector: ".limux-tab-rename-entry", - default_size: 12.0, + default_size: 11.0, }, UiFontDescriptor { id: "pane_action_icon", label: "Pane action icons", subtitle: "New tab, split, settings, close, and browser navigation icons in pane headers", selector: ".limux-pane-action image", - default_size: 16.0, + default_size: 12.0, }, UiFontDescriptor { id: "pane_tab_close_icon", @@ -142,7 +142,7 @@ pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ label: "Notification panel title", subtitle: "Header text in the notification panel", selector: ".limux-notification-panel-title", - default_size: 12.0, + default_size: 18.0, }, UiFontDescriptor { id: "notification_panel_empty", @@ -156,21 +156,21 @@ pub const UI_FONT_DESCRIPTORS: &[UiFontDescriptor] = &[ label: "Notification status dot", subtitle: "Attention and finished status marker in notification rows", selector: ".limux-notification-status", - default_size: 10.0, + default_size: 8.0, }, UiFontDescriptor { id: "notification_panel_workspace", label: "Notification workspace", subtitle: "Workspace label in notification rows", selector: ".limux-notification-workspace", - default_size: 11.0, + default_size: 10.0, }, UiFontDescriptor { id: "notification_panel_message", label: "Notification message", subtitle: "Primary message text in notification rows", selector: ".limux-notification-message", - default_size: 12.0, + default_size: 13.0, }, UiFontDescriptor { id: "notification_panel_detail", @@ -741,8 +741,8 @@ mod tests { assert!(css.contains(".limux-tab { font-size: 24px; }")); assert!(css.contains(".limux-pin-icon { font-size: 20px; }")); - assert!(css.contains(".limux-tab-status { font-size: 10px; }")); - assert!(css.contains(".limux-tab-rename-entry { font-size: 12px; }")); + assert!(css.contains(".limux-tab-status { font-size: 8px; }")); + assert!(css.contains(".limux-tab-rename-entry { font-size: 11px; }")); assert!(css.contains(".limux-pane-action image { -gtk-icon-size: 18px; }")); assert!(css.contains(".limux-tab-close image { -gtk-icon-size: 11px; }")); assert!(UI_FONT_DESCRIPTORS diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index 1c5db896..6c638435 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -106,6 +106,7 @@ pub(crate) struct AppState { sidebar_handle: gtk::Box, new_ws_btn: gtk::Button, notification_button: gtk::Button, + notification_badge: gtk::Label, notification_records: Vec<NotificationRecord>, next_notification_id: u64, sidebar_animation: Option<adw::TimedAnimation>, @@ -1249,10 +1250,10 @@ const SIDEBAR_RESIZE_HANDLE_WIDTH_PX: i32 = 3; const BASE_CSS: &str = r#" .limux-host-entry { - background-color: alpha(@window_bg_color, 0.98); + background-color: alpha(@window_bg_color, 0.96); color: @window_fg_color; - border: 1px solid alpha(@window_fg_color, 0.16); - border-radius: 6px; + border: 1px solid alpha(@window_fg_color, 0.14); + border-radius: 7px; caret-color: currentColor; } .limux-host-entry:focus-within { @@ -1269,10 +1270,17 @@ const BASE_CSS: &str = r#" color: alpha(@window_fg_color, 0.5); } .limux-sidebar { - background-color: @window_bg_color; + background-color: alpha(@window_bg_color, 0.98); color: @window_fg_color; border-right: 1px solid alpha(@window_fg_color, 0.08); } +.limux-sidebar-header { + padding: 4px 6px 0 6px; +} +.limux-sidebar .navigation-sidebar { + background: transparent; + padding: 8px 0; +} .limux-sidebar .navigation-sidebar > row { background: transparent; padding-left: 0; @@ -1281,19 +1289,21 @@ const BASE_CSS: &str = r#" margin-right: 0; } .limux-sidebar-row-box { - padding: 8px 7px 8px 4px; - border-radius: 7px; - margin: 2px 0; + padding: 8px 10px; + border-radius: 6px; + margin: 1px 6px; } .limux-sidebar .navigation-sidebar > row:selected { background: transparent; } row:selected .limux-sidebar-row-box { background-color: alpha(@accent_bg_color, 0.18); + box-shadow: inset 0 0 0 1px alpha(@accent_bg_color, 0.16); } .limux-ws-name { color: alpha(@window_fg_color, 0.72); - font-size: 15px; + font-size: 12.5px; + font-weight: 600; } row:selected .limux-ws-name { color: @window_fg_color; @@ -1303,8 +1313,8 @@ row:selected .limux-ws-name { border: none; min-height: 0; min-width: 0; - padding: 0 4px; - font-size: 22px; + padding: 0 2px 0 4px; + font-size: 9px; } .limux-ws-star-btn:hover { color: alpha(@window_fg_color, 0.9); @@ -1313,7 +1323,7 @@ row:selected .limux-ws-star-btn { color: alpha(@window_fg_color, 0.85); } .limux-ws-star-btn-active { - color: @accent_bg_color; + color: @accent_color; } .limux-ws-rename-entry { min-height: 0; @@ -1321,33 +1331,43 @@ row:selected .limux-ws-star-btn { margin: 0; } .limux-notify-dot { - color: @accent_bg_color; - font-size: 10px; - min-width: 12px; - margin-right: 6px; + background-color: @accent_bg_color; + color: @accent_fg_color; + font-size: 9px; + font-weight: 600; + min-width: 16px; + min-height: 16px; + border-radius: 999px; + padding: 0; + margin-right: 8px; } .limux-notify-dot-hidden { color: transparent; - font-size: 10px; - min-width: 12px; - margin-right: 6px; + background-color: transparent; + font-size: 9px; + min-width: 16px; + min-height: 16px; + padding: 0; + margin-right: 8px; } .limux-notify-dot-attention { - color: @accent_bg_color; + background-color: @accent_bg_color; + color: @accent_fg_color; } .limux-notify-dot-finished { - color: rgb(46, 194, 126); + background-color: rgb(46, 194, 126); + color: rgba(255, 255, 255, 0.95); } .limux-notify-msg { color: alpha(@window_fg_color, 0.35); - font-size: 11px; + font-size: 10px; } .limux-notify-msg-unread { - color: alpha(@accent_bg_color, 0.9); - font-size: 11px; + color: alpha(@accent_color, 0.9); + font-size: 10px; } .limux-notify-msg-attention { - color: alpha(@accent_bg_color, 0.94); + color: alpha(@accent_color, 0.94); font-weight: 600; } .limux-notify-msg-finished { @@ -1355,19 +1375,19 @@ row:selected .limux-ws-star-btn { font-weight: 600; } .limux-sidebar-row-unread { - background-color: alpha(@accent_bg_color, 0.16); + background-color: alpha(@accent_bg_color, 0.14); box-shadow: inset 3px 0 0 0 @accent_bg_color; border-radius: 6px; } .limux-sidebar-row-attention { - background-color: alpha(@accent_bg_color, 0.16); + background-color: alpha(@accent_bg_color, 0.14); box-shadow: inset 3px 0 0 0 @accent_bg_color; - border-radius: 7px; + border-radius: 6px; } .limux-sidebar-row-finished { - background-color: rgba(46, 194, 126, 0.14); + background-color: rgba(46, 194, 126, 0.13); box-shadow: inset 3px 0 0 0 rgb(46, 194, 126); - border-radius: 7px; + border-radius: 6px; } .limux-sidebar-row-unread .limux-ws-name { color: @window_fg_color; @@ -1378,6 +1398,17 @@ row:selected .limux-ws-star-btn { color: @window_fg_color; font-weight: 700; } +row:selected .limux-sidebar-row-box, +row:selected .limux-sidebar-row-unread, +row:selected .limux-sidebar-row-attention, +row:selected .limux-sidebar-row-finished { + background-color: alpha(@accent_bg_color, 0.18); + box-shadow: inset 0 0 0 1px alpha(@accent_bg_color, 0.16); +} +row:selected .limux-notify-dot { + background-color: @accent_bg_color; + color: @accent_fg_color; +} .limux-drop-above .limux-sidebar-row-box { border-radius: 0; box-shadow: 0 -2px 0 0 @accent_bg_color; @@ -1397,17 +1428,20 @@ row:selected .limux-ws-star-btn { color: alpha(@window_fg_color, 0.55); font-size: 11px; font-weight: 600; - letter-spacing: 1px; + letter-spacing: 0; } .limux-notification-button { background: transparent; color: alpha(@window_fg_color, 0.54); border: none; - border-radius: 6px; + border-radius: 8px; padding: 4px; min-width: 28px; min-height: 28px; } +.limux-notification-button image { + -gtk-icon-size: 16px; +} .limux-notification-button:hover { background: alpha(@window_fg_color, 0.08); color: @window_fg_color; @@ -1416,15 +1450,27 @@ row:selected .limux-ws-star-btn { color: @accent_color; background: alpha(@accent_bg_color, 0.12); } +.limux-notification-badge { + background-color: @accent_bg_color; + color: @accent_fg_color; + border-radius: 999px; + font-size: 9px; + font-weight: 600; + min-width: 16px; + min-height: 16px; + padding: 0; + margin-top: -3px; + margin-right: -4px; +} .limux-notification-panel { background-color: @popover_bg_color; color: @popover_fg_color; - min-width: 340px; - padding: 8px; + min-width: 380px; + padding: 16px; } .limux-notification-panel-title { - color: alpha(@popover_fg_color, 0.72); - font-size: 12px; + color: alpha(@popover_fg_color, 0.82); + font-size: 18px; font-weight: 700; } .limux-notification-empty { @@ -1433,24 +1479,25 @@ row:selected .limux-ws-star-btn { padding: 16px; } .limux-notification-row { - padding: 8px; - border-radius: 7px; + padding: 12px; + border-radius: 10px; + margin: 4px 0; } .limux-notification-row:hover { background: alpha(@popover_fg_color, 0.08); } .limux-notification-row-unread { - background: alpha(@accent_bg_color, 0.10); + background: alpha(@accent_bg_color, 0.12); } .limux-notification-row-attention { - box-shadow: inset 3px 0 0 0 @accent_bg_color; + box-shadow: inset 3px 0 0 0 @accent_bg_color, inset 0 0 0 1px alpha(@accent_bg_color, 0.22); } .limux-notification-row-finished { - box-shadow: inset 3px 0 0 0 rgb(46, 194, 126); + box-shadow: inset 3px 0 0 0 rgb(46, 194, 126), inset 0 0 0 1px rgba(46, 194, 126, 0.22); } .limux-notification-status { - font-size: 10px; - min-width: 12px; + font-size: 8px; + min-width: 8px; } .limux-notification-status-attention { color: @accent_color; @@ -1460,11 +1507,11 @@ row:selected .limux-ws-star-btn { } .limux-notification-workspace { color: alpha(@popover_fg_color, 0.48); - font-size: 11px; + font-size: 10px; } .limux-notification-message { color: @popover_fg_color; - font-size: 12px; + font-size: 13px; font-weight: 600; } .limux-notification-detail { @@ -1507,17 +1554,19 @@ row:selected .limux-ws-star-btn { } .limux-ws-path { color: alpha(@window_fg_color, 0.3); - font-size: 12px; + font-size: 10px; + font-family: monospace; } .limux-ws-meta-row { - margin-left: 8px; + margin-left: 0; } .limux-ws-branch { background-color: alpha(@accent_bg_color, 0.13); color: alpha(@accent_color, 0.95); border-radius: 5px; padding: 1px 5px; - font-size: 11px; + font-size: 10px; + font-family: monospace; font-weight: 600; } .limux-ws-ports { @@ -1525,7 +1574,8 @@ row:selected .limux-ws-star-btn { color: alpha(@window_fg_color, 0.58); border-radius: 5px; padding: 1px 5px; - font-size: 11px; + font-size: 10px; + font-family: monospace; font-weight: 600; } row:selected .limux-ws-path { @@ -1691,12 +1741,22 @@ pub fn build_window(app: &adw::Application) { .build(); sidebar_title_label.add_css_class("limux-sidebar-title"); - let notification_button = - gtk::Button::from_icon_name("preferences-system-notifications-symbolic"); + let notification_button = gtk::Button::new(); notification_button.add_css_class("limux-notification-button"); notification_button.set_tooltip_text(Some( &shortcuts.tooltip_text(ShortcutId::OpenNotificationPanel, "Notifications"), )); + let notification_icon = gtk::Image::from_icon_name("preferences-system-notifications-symbolic"); + let notification_badge = gtk::Label::new(None); + notification_badge.add_css_class("limux-notification-badge"); + notification_badge.set_halign(gtk::Align::End); + notification_badge.set_valign(gtk::Align::Start); + notification_badge.set_visible(false); + let notification_overlay = gtk::Overlay::new(); + notification_overlay.set_child(Some(¬ification_icon)); + notification_overlay.add_overlay(¬ification_badge); + notification_overlay.set_clip_overlay(¬ification_badge, false); + notification_button.set_child(Some(¬ification_overlay)); let sidebar_title = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -1704,6 +1764,7 @@ pub fn build_window(app: &adw::Application) { .margin_bottom(4) .margin_end(6) .build(); + sidebar_title.add_css_class("limux-sidebar-header"); sidebar_title.append(&sidebar_title_label); sidebar_title.append(¬ification_button); @@ -1791,6 +1852,7 @@ pub fn build_window(app: &adw::Application) { sidebar_handle: sidebar_handle.clone(), new_ws_btn: new_ws_btn.clone(), notification_button: notification_button.clone(), + notification_badge: notification_badge.clone(), notification_records: Vec::new(), next_notification_id: 1, sidebar_animation: None, @@ -3000,13 +3062,28 @@ fn set_notification_button_unread(button: >k::Button, unread: bool) { } fn sync_notification_button_state(state: &AppState) { - set_notification_button_unread( - &state.notification_button, + let unread_count = state + .notification_records + .iter() + .filter(|record| record.unread) + .count(); + set_notification_button_unread(&state.notification_button, unread_count > 0); + if unread_count > 0 { state - .notification_records - .iter() - .any(|record| record.unread), - ); + .notification_badge + .set_label(¬ification_badge_text(unread_count)); + state.notification_badge.set_visible(true); + } else { + state.notification_badge.set_visible(false); + } +} + +fn notification_badge_text(count: usize) -> String { + if count > 9 { + "9+".to_string() + } else { + count.to_string() + } } fn append_notification_record(state: &mut AppState, record: NotificationRecord) { @@ -3106,6 +3183,7 @@ fn build_notification_record_row(record: &NotificationRecord) -> gtk::ListBoxRow let status = gtk::Label::builder() .label("\u{25CF}") .valign(gtk::Align::Start) + .margin_top(6) .build(); status.add_css_class("limux-notification-status"); status.add_css_class(record.kind.panel_status_class()); @@ -3131,7 +3209,7 @@ fn build_notification_record_row(record: &NotificationRecord) -> gtk::ListBoxRow let text = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(2) + .spacing(6) .hexpand(true) .build(); text.append(&workspace); @@ -3149,7 +3227,7 @@ fn build_notification_record_row(record: &NotificationRecord) -> gtk::ListBoxRow let row_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .spacing(8) + .spacing(12) .build(); row_box.add_css_class("limux-notification-row"); row_box.add_css_class(record.kind.panel_row_class()); @@ -3371,7 +3449,7 @@ fn build_sidebar_row( gtk::Label, gtk::Label, ) { - let notify_dot = gtk::Label::builder().label("\u{25CF}").build(); + let notify_dot = gtk::Label::builder().label("1").build(); notify_dot.add_css_class("limux-notify-dot-hidden"); let name_label = gtk::Label::builder() @@ -3420,7 +3498,7 @@ fn build_sidebar_row( let meta_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) - .spacing(5) + .spacing(3) .build(); meta_row.add_css_class("limux-ws-meta-row"); meta_row.append(&path_label); @@ -3433,13 +3511,12 @@ fn build_sidebar_row( .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::End) .visible(false) - .margin_start(8) .build(); notify_label.add_css_class("limux-notify-msg"); let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(2) + .spacing(4) .build(); vbox.add_css_class("limux-sidebar-row-box"); vbox.append(&top_row); @@ -6685,11 +6762,15 @@ fn apply_workspace_notification_visuals( workspace: &Workspace, message: &str, kind: NotificationVisualKind, + unread_count: usize, ) { clear_workspace_notification_visuals(workspace); workspace .notify_dot .remove_css_class("limux-notify-dot-hidden"); + workspace + .notify_dot + .set_label(¬ification_badge_text(unread_count.max(1))); workspace.notify_dot.add_css_class("limux-notify-dot"); workspace.notify_dot.add_css_class(kind.sidebar_dot_class()); @@ -6761,6 +6842,11 @@ fn mark_workspace_unread_with_message( let active_idx = s.active_idx; let window_active = s.window.is_active(); let notifications = s.config.borrow().notifications; + let unread_count = s + .notification_records + .iter() + .filter(|record| record.target.workspace_id == ws_id && record.unread) + .count(); if let Some((idx, ws)) = s .workspaces .iter_mut() @@ -6783,7 +6869,7 @@ fn mark_workspace_unread_with_message( if should_show_workspace_unread_marker(workspace_is_active, source_focused) { ws.unread = true; - apply_workspace_notification_visuals(ws, message, kind); + apply_workspace_notification_visuals(ws, message, kind, unread_count); } return desktop_request; @@ -7145,6 +7231,8 @@ mod tests { assert!(!BASE_CSS.contains(":root")); assert!(!BASE_CSS.contains("@media")); assert!(!BASE_CSS.contains("var(")); + assert!(!BASE_CSS.contains("limux_cmux_accent")); + assert!(!BASE_CSS.contains("rgb(0, 145, 255)")); assert!(BASE_CSS.contains(".limux-host-entry")); assert!(BASE_CSS.contains(".limux-host-entry text")); assert!(BASE_CSS.contains(".limux-host-entry text placeholder")); @@ -7260,7 +7348,7 @@ mod tests { } #[test] - fn sidebar_workspace_rows_remove_theme_horizontal_insets() { + fn sidebar_workspace_rows_use_cmux_metrics_without_theme_insets() { let row_rule = BASE_CSS .split(".limux-sidebar .navigation-sidebar > row {") .nth(1) @@ -7276,7 +7364,20 @@ mod tests { .nth(1) .and_then(|rest| rest.split('}').next()) .expect("sidebar row box CSS rule"); - assert!(row_box_rule.contains("margin: 2px 0;")); + assert!(row_box_rule.contains("padding: 8px 10px;")); + assert!(row_box_rule.contains("border-radius: 6px;")); + assert!(row_box_rule.contains("margin: 1px 6px;")); + } + + #[test] + fn sidebar_selected_row_uses_system_accent_tint() { + let selected_rule = BASE_CSS + .split("row:selected .limux-sidebar-row-box {") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .expect("selected sidebar row CSS rule"); + assert!(selected_rule.contains("background-color: alpha(@accent_bg_color, 0.18);")); + assert!(!selected_rule.contains("background-color: @accent_bg_color;")); } #[test] From 3d54de788800cff6b3e96517b978b5e028cbc40e Mon Sep 17 00:00:00 2001 From: Pavlo Grubyi <batcavepost@gmail.com> Date: Mon, 8 Jun 2026 07:32:06 +0100 Subject: [PATCH 4/4] chore: document local dogfood workflow --- AGENTS.md | 8 ++ Cargo.toml | 1 + README.md | 13 ++- docs/development-workflow.md | 114 +++++++++++++++++++++ rust-toolchain.toml | 4 + rust/limux-cli/Cargo.toml | 1 + rust/limux-control/Cargo.toml | 1 + rust/limux-core/Cargo.toml | 1 + rust/limux-ghostty-sys/Cargo.toml | 1 + rust/limux-host-linux/Cargo.toml | 1 + rust/limux-protocol/Cargo.toml | 1 + scripts/local-build-status.sh | 158 ++++++++++++++++++++++++++++++ 12 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 docs/development-workflow.md create mode 100644 rust-toolchain.toml create mode 100755 scripts/local-build-status.sh diff --git a/AGENTS.md b/AGENTS.md index 10656b95..d31a1bb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,13 @@ freshly installed CLI and that the host resolves the matching local library. Restart any already-running Limux GUI before validating the new behavior; a running process keeps using the old mapped executable. +Use the status check when you need to confirm this PC is dogfooding the local +build: + +```bash +./scripts/local-build-status.sh +``` + If a change touches release packaging, Ghostty resources, system linker config, or distro artifacts, use the full package path instead: @@ -159,6 +166,7 @@ name addressing, and by-name send path. Useful references: +- Feature and dogfood workflow: `docs/development-workflow.md` - Roadmap/current bridge status: `docs/cmux-parity-plan.md` - Maintainability rules: `docs/maintainability.md` - CLI usage: `README.md` and `./target/debug/limux-cli --help` diff --git a/Cargo.toml b/Cargo.toml index f263928e..cdcb3faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ resolver = "2" authors = ["limux contributors"] edition = "2021" license = "MIT" +rust-version = "1.92" version = "0.1.19" [workspace.dependencies] diff --git a/README.md b/README.md index fad4d086..251f43ab 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ stub or copied `libghostty.so` is not enough to build a working host. ### Prerequisites -- Rust toolchain 1.92 or newer +- Rust toolchain 1.92 or newer. The checkout selects Rust 1.92 through + `rust-toolchain.toml` so local scripts do not depend on your global default. - Zig - GTK4, libadwaita, WebKitGTK dev packages - Initialized Ghostty submodule @@ -121,6 +122,9 @@ This builds the binary, bundles `libghostty.so`, icons, and an install script in ## Development +The contributor workflow for shipping features while dogfooding the local build +is documented in [`docs/development-workflow.md`](docs/development-workflow.md). + Run the canonical local quality gate before committing: ```bash @@ -139,7 +143,12 @@ validating it through the desktop entry or the `limux` command on `PATH`: The script builds the CLI and GTK host, installs them under `$LIMUX_LOCAL_PREFIX` or `~/.local`, copies a matching `libghostty.so`, and verifies that the active `limux` entrypoint resolves to the fresh local build. -Restart any already-running Limux GUI after installing. +Restart any already-running Limux GUI after installing, then confirm the active +local runtime: + +```bash +./scripts/local-build-status.sh +``` ## Agent integrations diff --git a/docs/development-workflow.md b/docs/development-workflow.md new file mode 100644 index 00000000..855bf8c3 --- /dev/null +++ b/docs/development-workflow.md @@ -0,0 +1,114 @@ +# Development Workflow + +This repo has one delivery loop: build the feature in the workspace, verify it +against the right runtime path, then install that same build for local +dogfooding when the change is user-visible. + +The checkout pins Rust 1.92 in `rust-toolchain.toml`. Keep that in sync with +the `rust-version` inherited by every crate from the workspace manifest. + +## Repository Layout + +- `rust/limux-protocol`: shared JSON envelopes and protocol types. +- `rust/limux-core`: in-process command dispatcher and state engine. +- `rust/limux-control`: Unix socket auth, framing, and standalone server. +- `rust/limux-ghostty-sys`: raw Ghostty C API bindings. +- `rust/limux-host-linux`: GTK4/libadwaita host, pane UI, terminal embedding, + and the production control bridge. +- `rust/limux-cli`: user-facing `limux` CLI and agent integration commands. +- `scripts/`: quality, smoke, local install, and release packaging entrypoints. +- `docs/`: workflow, architecture notes, testing notes, and active plans. + +Treat `ghostty/` as vendored input from Limux's point of view. Use Ghostty's C +API rather than editing the vendored tree for Limux features. + +## Feature Loop + +1. Make the smallest change that fits the crate boundary. +2. Run a narrow check while iterating: + + ```bash + cargo check -p limux-host-linux + cargo test -p limux-cli + cargo check --workspace + ``` + +3. Run the canonical gate before handoff or commit: + + ```bash + ./scripts/check.sh + ``` + +4. For live CLI, socket, agent, pane, or notification behavior, exercise the + production GTK bridge with the smoke harness: + + ```bash + LIMUX_SMOKE_PROFILE=debug ./scripts/xvfb-smoke-test.sh + ./scripts/xvfb-smoke-test.sh + ``` + +The standalone dispatcher is useful for unit tests, but user-visible CLI +behavior must be checked through the running GTK bridge when possible. + +## Local Dogfood Loop + +Install the local build that this PC should actually run: + +```bash +./scripts/install-local-build.sh +``` + +By default this installs under `~/.local`. Set `LIMUX_LOCAL_PREFIX` to install +somewhere else, and set `LIMUX_LOCAL_PROFILE=debug` when a debug build is more +useful than release: + +```bash +LIMUX_LOCAL_PROFILE=debug ./scripts/install-local-build.sh +``` + +After installing, restart any already-running Limux GUI. A running process keeps +using the executable and library it mapped at startup. + +Confirm the machine is dogfooding the local build: + +```bash +./scripts/local-build-status.sh +``` + +The expected local runtime layout is: + +- CLI entrypoint: `$LIMUX_LOCAL_PREFIX/bin/limux` or `~/.local/bin/limux`. +- Host wrapper: `$LIMUX_LOCAL_PREFIX/libexec/limux/limux-host`. +- Host binary: `$LIMUX_LOCAL_PREFIX/libexec/limux/limux-host.bin`. +- Ghostty library: `$LIMUX_LOCAL_PREFIX/lib/limux/libghostty.so`. +- Build info: `$LIMUX_LOCAL_PREFIX/share/limux/local-build.txt`. + +If `command -v limux` points at an older package, put the local prefix's `bin` +directory earlier on `PATH` before dogfooding: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +## Release Loop + +Use the release packaging path when a change touches packaging, Ghostty +resources, system linker behavior, distro metadata, or release artifacts: + +```bash +./scripts/package.sh +``` + +CI release workflows build the Linux tarball, deb, AppImage, and RPM on the +Ubuntu 24.04 GLIBC floor. The AUR workflow publishes from release tarballs. + +## Handoff Checklist + +- Intended files only are changed. +- `./scripts/check.sh` passes, unless an explicit blocker is documented. +- Live GTK bridge behavior is smoke-tested for CLI or runtime changes. +- `./scripts/install-local-build.sh` has been run for user-visible local fixes. +- `./scripts/local-build-status.sh` confirms this PC's `limux` resolves to the + local build. +- Any already-running Limux GUI has been restarted before claiming dogfood + coverage. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..34952324 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.92" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rust/limux-cli/Cargo.toml b/rust/limux-cli/Cargo.toml index ac61dd8b..d98b069b 100644 --- a/rust/limux-cli/Cargo.toml +++ b/rust/limux-cli/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true [dependencies] anyhow.workspace = true diff --git a/rust/limux-control/Cargo.toml b/rust/limux-control/Cargo.toml index d1e8b927..778b75bd 100644 --- a/rust/limux-control/Cargo.toml +++ b/rust/limux-control/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true [lib] crate-type = ["rlib", "staticlib"] diff --git a/rust/limux-core/Cargo.toml b/rust/limux-core/Cargo.toml index 5ecb8f5a..343b112d 100644 --- a/rust/limux-core/Cargo.toml +++ b/rust/limux-core/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true [dependencies] anyhow.workspace = true diff --git a/rust/limux-ghostty-sys/Cargo.toml b/rust/limux-ghostty-sys/Cargo.toml index 0e948f13..6ac90c11 100644 --- a/rust/limux-ghostty-sys/Cargo.toml +++ b/rust/limux-ghostty-sys/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true build = "build.rs" [build-dependencies] diff --git a/rust/limux-host-linux/Cargo.toml b/rust/limux-host-linux/Cargo.toml index 3f64c26e..b2d05b52 100644 --- a/rust/limux-host-linux/Cargo.toml +++ b/rust/limux-host-linux/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true [[bin]] name = "limux" diff --git a/rust/limux-protocol/Cargo.toml b/rust/limux-protocol/Cargo.toml index 1e840a23..d5fb586b 100644 --- a/rust/limux-protocol/Cargo.toml +++ b/rust/limux-protocol/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true +rust-version.workspace = true [dependencies] serde.workspace = true diff --git a/scripts/local-build-status.sh b/scripts/local-build-status.sh new file mode 100755 index 00000000..00be49db --- /dev/null +++ b/scripts/local-build-status.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PREFIX="${LIMUX_LOCAL_PREFIX:-$HOME/.local}" + +CLI_DEST="$PREFIX/bin/limux" +HOST_DIR="$PREFIX/libexec/limux" +HOST_WRAPPER="$HOST_DIR/limux-host" +HOST_BIN_DEST="$HOST_DIR/limux-host.bin" +LIB_DEST="$PREFIX/lib/limux/libghostty.so" +BUILD_INFO_DEST="$PREFIX/share/limux/local-build.txt" + +failures=0 + +fail() { + echo "FAIL: $*" >&2 + failures=$((failures + 1)) +} + +warn() { + echo "WARN: $*" >&2 +} + +pass() { + echo "OK: $*" +} + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + fail "$1 is required" + return 1 + fi +} + +realpath_of() { + readlink -f "$1" 2>/dev/null || true +} + +need_cmd awk +need_cmd git +need_cmd grep +need_cmd ldd +need_cmd readlink + +echo "Limux local build status" +echo " repo: $ROOT_DIR" +echo " prefix: $PREFIX" + +ACTIVE_LIMUX="$(command -v limux 2>/dev/null || true)" +if [ -z "$ACTIVE_LIMUX" ]; then + fail "limux is not on PATH" +else + ACTIVE_REAL="$(realpath_of "$ACTIVE_LIMUX")" + EXPECTED_REAL="$(realpath_of "$CLI_DEST")" + if [ -n "$EXPECTED_REAL" ] && [ "$ACTIVE_REAL" = "$EXPECTED_REAL" ]; then + pass "active limux resolves to $CLI_DEST" + else + fail "active limux is $ACTIVE_LIMUX, expected $CLI_DEST" + fi +fi + +if [ -x "$CLI_DEST" ]; then + pass "CLI exists at $CLI_DEST" + if "$CLI_DEST" --help 2>&1 | grep -q "limux CLI"; then + pass "CLI help identifies the Limux CLI entrypoint" + else + fail "$CLI_DEST does not look like the Limux CLI entrypoint" + fi +else + fail "CLI is missing or not executable at $CLI_DEST" +fi + +if [ -x "$HOST_WRAPPER" ]; then + pass "host wrapper exists at $HOST_WRAPPER" +else + fail "host wrapper is missing or not executable at $HOST_WRAPPER" +fi + +if [ -x "$HOST_BIN_DEST" ]; then + pass "host binary exists at $HOST_BIN_DEST" +else + fail "host binary is missing or not executable at $HOST_BIN_DEST" +fi + +if [ -f "$LIB_DEST" ]; then + pass "libghostty.so exists at $LIB_DEST" +else + fail "libghostty.so is missing at $LIB_DEST" +fi + +if [ -x "$HOST_BIN_DEST" ] && [ -f "$LIB_DEST" ]; then + RESOLVED_LIB="$( + LD_LIBRARY_PATH="$PREFIX/lib/limux${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + ldd "$HOST_BIN_DEST" \ + | awk '/libghostty\.so/ {print $3; exit}' + )" + if [ -z "$RESOLVED_LIB" ]; then + fail "host binary does not resolve libghostty.so" + elif [ "$(realpath_of "$RESOLVED_LIB")" = "$(realpath_of "$LIB_DEST")" ]; then + pass "host resolves libghostty.so from the local prefix" + else + fail "host resolves libghostty.so to $RESOLVED_LIB, expected $LIB_DEST" + fi +fi + +if [ -x "$HOST_WRAPPER" ]; then + if "$HOST_WRAPPER" --version >/dev/null 2>&1; then + pass "host wrapper executes" + else + fail "host wrapper failed to execute" + fi +fi + +if [ -f "$BUILD_INFO_DEST" ]; then + echo "Build info:" + sed 's/^/ /' "$BUILD_INFO_DEST" + + INSTALLED_HEAD="$(awk -F= '/^git_head=/ {print $2; exit}' "$BUILD_INFO_DEST")" + CURRENT_HEAD="$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || true)" + INSTALLED_DIRTY="$(awk -F= '/^git_dirty=/ {print $2; exit}' "$BUILD_INFO_DEST")" + + if [ -n "$INSTALLED_HEAD" ] && [ -n "$CURRENT_HEAD" ] && [ "$INSTALLED_HEAD" = "$CURRENT_HEAD" ]; then + pass "installed build git head matches the current checkout" + elif [ -n "$INSTALLED_HEAD" ] && [ -n "$CURRENT_HEAD" ]; then + fail "installed build git head is $INSTALLED_HEAD, current checkout is $CURRENT_HEAD" + else + warn "could not compare installed build git head with the current checkout" + fi + + if [ "$INSTALLED_DIRTY" = "true" ]; then + warn "installed build was produced from a dirty worktree" + fi +else + warn "build info is missing at $BUILD_INFO_DEST; run ./scripts/install-local-build.sh" +fi + +if command -v pgrep >/dev/null 2>&1; then + RUNNING_HOSTS="$(pgrep -a -u "$(id -u)" -f 'limux-host(.bin)?|libexec/limux/limux-host' 2>/dev/null || true)" + if [ -n "$RUNNING_HOSTS" ]; then + echo "Running Limux host processes:" + printf '%s\n' "$RUNNING_HOSTS" | sed 's/^/ /' + else + echo "Running Limux host processes: none detected" + fi +else + warn "pgrep is not available; skipping running host process check" +fi + +if [ "$failures" -ne 0 ]; then + echo + echo "Local dogfood status: FAILED" + echo "Run ./scripts/install-local-build.sh, ensure $PREFIX/bin is first on PATH, then restart Limux." + exit 1 +fi + +echo +echo "Local dogfood status: OK"