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/AGENTS.md b/AGENTS.md index 6ba4ffd4..d31a1bb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,39 @@ 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. + +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: + +```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: @@ -133,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 11660afb..251f43ab 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,31 @@ 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. 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 @@ -88,7 +108,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 @@ -102,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 @@ -110,10 +133,27 @@ 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, then confirm the active +local runtime: + +```bash +./scripts/local-build-status.sh +``` + ## 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 +168,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 +196,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/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/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-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-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-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-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/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-host-linux/icons/app/128.png b/rust/limux-host-linux/icons/app/128.png index 34192bba..4bbcdaac 100644 Binary files a/rust/limux-host-linux/icons/app/128.png and b/rust/limux-host-linux/icons/app/128.png differ diff --git a/rust/limux-host-linux/icons/app/16.png b/rust/limux-host-linux/icons/app/16.png index 20f65a4d..035e44c5 100644 Binary files a/rust/limux-host-linux/icons/app/16.png and b/rust/limux-host-linux/icons/app/16.png differ diff --git a/rust/limux-host-linux/icons/app/256.png b/rust/limux-host-linux/icons/app/256.png index 0bcb0745..24659ada 100644 Binary files a/rust/limux-host-linux/icons/app/256.png and b/rust/limux-host-linux/icons/app/256.png differ diff --git a/rust/limux-host-linux/icons/app/32.png b/rust/limux-host-linux/icons/app/32.png index 6f3c383f..5950fccc 100644 Binary files a/rust/limux-host-linux/icons/app/32.png and b/rust/limux-host-linux/icons/app/32.png differ diff --git a/rust/limux-host-linux/icons/app/512.png b/rust/limux-host-linux/icons/app/512.png index 4c1dd600..6274bb10 100644 Binary files a/rust/limux-host-linux/icons/app/512.png and b/rust/limux-host-linux/icons/app/512.png differ 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/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/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..63cf115d 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]; @@ -304,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); @@ -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: 8px; + 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; @@ -341,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); @@ -372,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; } @@ -387,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; @@ -418,6 +481,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 +790,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 +2007,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 +2023,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 +2039,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 +2235,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 +2296,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 +2326,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 +2342,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 +2356,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 +2553,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 +2946,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 +2993,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); @@ -3221,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"); @@ -3479,7 +3729,21 @@ 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] + 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] diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs index 225fe1ca..8c0f9790 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: 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: 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: 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: 10.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: 10.0, + }, + UiFontDescriptor { + id: "sidebar_git_branch", + label: "Sidebar git branch", + subtitle: "Git branch pill in workspace rows", + selector: ".limux-ws-branch", + default_size: 10.0, + }, + UiFontDescriptor { + id: "sidebar_ports", + label: "Sidebar ports", + subtitle: "Localhost port pill in workspace rows", + selector: ".limux-ws-ports", + 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: 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: 8.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: 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: 12.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: 18.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: 8.0, + }, + UiFontDescriptor { + id: "notification_panel_workspace", + label: "Notification workspace", + subtitle: "Workspace label in notification rows", + selector: ".limux-notification-workspace", + 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: 13.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: 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 + .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..6c638435 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,10 @@ pub(crate) struct AppState { sidebar_shell: gtk::Box, 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>, sidebar_animation_epoch: u64, sidebar_expanded_width: i32, @@ -203,6 +234,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 +789,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 +821,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,55 +1249,61 @@ 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); - border-radius: 6px; + background-color: alpha(@window_bg_color, 0.96); + color: @window_fg_color; + border: 1px solid alpha(@window_fg_color, 0.14); + border-radius: 7px; 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; + 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; + padding-right: 0; + margin-left: 0; + margin-right: 0; +} .limux-sidebar-row-box { - padding: 8px 6px 8px 3px; + padding: 8px 10px; border-radius: 6px; - margin: 2px 3px 2px 1px; + 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; @@ -1206,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); @@ -1216,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; @@ -1224,34 +1331,84 @@ row:selected .limux-ws-star-btn { margin: 0; } .limux-notify-dot { - color: @accent_bg_color; - font-size: 10px; - 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; - margin-right: 6px; + background-color: transparent; + font-size: 9px; + min-width: 16px; + min-height: 16px; + padding: 0; + margin-right: 8px; +} +.limux-notify-dot-attention { + background-color: @accent_bg_color; + color: @accent_fg_color; +} +.limux-notify-dot-finished { + 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_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; + 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.14); + box-shadow: inset 3px 0 0 0 @accent_bg_color; + border-radius: 6px; +} +.limux-sidebar-row-finished { + background-color: rgba(46, 194, 126, 0.13); + box-shadow: inset 3px 0 0 0 rgb(46, 194, 126); border-radius: 6px; - margin-left: 0; - margin-right: 0; } .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; +} +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; @@ -1271,7 +1428,95 @@ 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: 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; +} +.limux-notification-button-unread { + 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: 380px; + padding: 16px; +} +.limux-notification-panel-title { + color: alpha(@popover_fg_color, 0.82); + font-size: 18px; + font-weight: 700; +} +.limux-notification-empty { + color: alpha(@popover_fg_color, 0.45); + font-size: 12px; + padding: 16px; +} +.limux-notification-row { + 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.12); +} +.limux-notification-row-attention { + 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), inset 0 0 0 1px rgba(46, 194, 126, 0.22); +} +.limux-notification-status { + font-size: 8px; + min-width: 8px; +} +.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: 10px; +} +.limux-notification-message { + color: @popover_fg_color; + font-size: 13px; + 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); @@ -1309,11 +1554,41 @@ 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: 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: 10px; + font-family: monospace; + 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: 10px; + font-family: monospace; + 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 +1603,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 +1650,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,13 +1741,32 @@ pub fn build_window(app: &adw::Application) { .build(); sidebar_title_label.add_css_class("limux-sidebar-title"); + 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) .margin_top(8) .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); { let window = window.clone(); @@ -1533,6 +1841,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 +1851,10 @@ 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_badge: notification_badge.clone(), + notification_records: Vec::new(), + next_notification_id: 1, sidebar_animation: None, sidebar_animation_epoch: 0, sidebar_expanded_width: SIDEBAR_WIDTH, @@ -1562,6 +1875,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 +2017,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 +2395,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 +2526,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 +2541,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 +3053,290 @@ 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) { + 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_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) { + 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) + .margin_top(6) + .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(6) + .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(12) + .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, @@ -2808,109 +3421,389 @@ fn activate_workspace_shortcut(state: &State, idx: usize) { } } -fn activate_last_workspace_shortcut(state: &State) { - let last_idx = { - let s = state.borrow(); - if s.workspaces.is_empty() { - return; - } - s.workspaces.len() - 1 - }; - activate_workspace_shortcut(state, last_idx); +fn activate_last_workspace_shortcut(state: &State) { + let last_idx = { + let s = state.borrow(); + if s.workspaces.is_empty() { + return; + } + s.workspaces.len() - 1 + }; + activate_workspace_shortcut(state, last_idx); +} + +// --------------------------------------------------------------------------- +// 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("1").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(3) + .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) + .build(); + notify_label.add_css_class("limux-notify-msg"); + + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .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 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) +} + +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() } -// --------------------------------------------------------------------------- -// Sidebar row -// --------------------------------------------------------------------------- +fn parse_proc_net_listening_ports(raw: &str) -> Vec<(u64, u16)> { + raw.lines() + .skip(1) + .filter_map(parse_proc_net_tcp_line) + .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 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 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 (_, port_hex) = columns[1].split_once(':')?; + let port = u16::from_str_radix(port_hex, 16).ok()?; + if port == 0 { + return None; + } - 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 inode = columns[9].parse::<u64>().ok()?; + Some((inode, port)) +} - 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_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 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); +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()?; } +} - 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(&path_label); - vbox.append(¬ify_label); - - let row = gtk::ListBoxRow::new(); - row.set_child(Some(&vbox)); +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) +} - ( - row, - name_label, - favorite_button, - notify_dot, - notify_label, - path_label, - ) +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) } -/// 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_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 +4315,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 +4348,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 +4361,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 +5344,8 @@ fn handle_control_command(state: &State, command: ControlCommand) { } ControlCommand::CreateNotification { target, + surface_hint, + kind, title, subtitle, body, @@ -4468,15 +5375,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 +5419,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 +5453,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 +5482,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 +5498,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 +5574,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 +5627,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 +5673,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 +5686,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 +5746,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 +5785,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 +5811,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 +6281,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 +6290,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 +6683,139 @@ 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, + 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()); + + 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,11 +6836,17 @@ 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; 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() @@ -5760,18 +6867,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, unread_count); } return desktop_request; @@ -5877,15 +6975,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 +7222,122 @@ 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_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")); + 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 +7347,128 @@ mod tests { assert!(BASE_CSS.contains(".limux-ws-rename-entry")); } + #[test] + fn sidebar_workspace_rows_use_cmux_metrics_without_theme_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("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] + 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 +7660,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/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/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." 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" 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