diff --git a/Documentation.md b/Documentation.md
index af445c5..1520ab5 100644
--- a/Documentation.md
+++ b/Documentation.md
@@ -429,6 +429,29 @@ Activate a named profile. Emits `bread.profile.activated` over IPC.
#### `bread.exec(cmd)`
Run a shell command. Fire-and-forget (async, does not block Lua).
+#### `bread.exec_capture(cmd, opts) -> ok, stdout`
+Run a shell command and return its result: `ok` is whether it exited zero,
+`stdout` is its captured standard output. Unlike `bread.exec`, this blocks
+the calling Lua callback until the command exits (or the timeout below
+elapses), so it's only appropriate for fast, local commands — e.g.
+`git -C
rev-parse --abbrev-ref HEAD`, not anything that hits the
+network or waits on user input.
+
+```lua
+local ok, branch = bread.exec_capture("git -C " .. dir .. " rev-parse --abbrev-ref HEAD")
+if ok then
+ branch = branch:gsub("%s+$", "") -- trailing newline
+end
+```
+
+Options:
+
+| Key | Type | Default |
+|-----|------|---------|
+| `timeout_ms` | number | `2000` |
+
+On timeout or spawn failure, returns `false, ""`.
+
### Notifications
#### `bread.notify(message, opts)`
@@ -494,9 +517,20 @@ Read a file. Returns `nil` if the file does not exist. `~` is expanded.
#### `bread.fs.exists(path) -> bool`
Returns true if the path exists. `~` is expanded.
+#### `bread.fs.readlink(path) -> string | nil`
+Read a symlink's target. Returns `nil` if the path doesn't exist or isn't a
+symlink. Distinct from `bread.fs.read`, which opens and reads file
+*contents* — for something like `/proc//cwd`, the payload is the link
+target itself, not a file to read.
+
#### `bread.fs.expand(path) -> string`
Expand `~` to the home directory.
+#### `bread.json.decode(str) -> table | nil`
+Parse a JSON string into a Lua table. Returns `nil` on malformed input.
+Pairs naturally with `bread.exec_capture` for consuming JSON output from a
+CLI (e.g. `kitty @ ls`).
+
### Hyprland
The `bread.hyprland` namespace provides compositor bindings.
diff --git a/breadd/src/lua/mod.rs b/breadd/src/lua/mod.rs
index 0f8a049..edbf437 100644
--- a/breadd/src/lua/mod.rs
+++ b/breadd/src/lua/mod.rs
@@ -582,6 +582,43 @@ impl LuaEngine {
})?;
bread.set("exec", exec_fn)?;
+ // `bread.exec` is deliberately fire-and-forget (spawn_blocking, no
+ // result). This is the capturing counterpart for the common "run a
+ // fast local command and read its stdout back into Lua" case (e.g.
+ // `git -C rev-parse --abbrev-ref HEAD`). It blocks the calling
+ // Lua callback for real, so it's only appropriate for quick
+ // commands — hence the timeout. The subprocess itself runs on a
+ // plain std::thread (not spawn_blocking) so the Lua thread can wait
+ // on a channel with a deadline; `Command::output()` drains stdout
+ // internally as it reads, so a chatty command can't deadlock this by
+ // filling a pipe buffer while nobody's reading it. On timeout the
+ // spawned thread and its child are left to finish/exit on their own
+ // rather than force-killed — acceptable for the fast-command case
+ // this exists for, not worth the extra complexity for a rare hang.
+ let exec_capture_fn =
+ self.lua
+ .create_function(|_lua, (cmd, opts): (String, Option)| {
+ let timeout_ms: u64 = opts
+ .as_ref()
+ .and_then(|o| o.get("timeout_ms").ok())
+ .unwrap_or(2000);
+
+ let (tx, rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let result = std::process::Command::new("sh").arg("-c").arg(&cmd).output();
+ let _ = tx.send(result);
+ });
+
+ match rx.recv_timeout(std::time::Duration::from_millis(timeout_ms)) {
+ Ok(Ok(output)) => Ok((
+ output.status.success(),
+ String::from_utf8_lossy(&output.stdout).to_string(),
+ )),
+ Ok(Err(_)) | Err(_) => Ok((false, String::new())),
+ }
+ })?;
+ bread.set("exec_capture", exec_capture_fn)?;
+
let notify_path = self.notifications_config.notify_send_path.clone();
let default_urgency = self.notifications_config.default_urgency.clone();
let default_timeout = self.notifications_config.default_timeout_ms;
@@ -1078,6 +1115,19 @@ impl LuaEngine {
.create_function(|_lua, path: String| Ok(lua_expand_path(&path).exists()))?;
fs_tbl.set("exists", exists_fn)?;
+ // Distinct from `read`: `/proc//cwd` and friends are symlinks
+ // whose *target path* is the payload, not a file to open and read —
+ // `std::fs::read_to_string` on one of those fails with "Is a
+ // directory" (or reads the wrong thing for a symlink-to-file).
+ let readlink_fn = self.lua.create_function(|_lua, path: String| {
+ let expanded = lua_expand_path(&path);
+ match std::fs::read_link(&expanded) {
+ Ok(target) => Ok(Some(target.to_string_lossy().to_string())),
+ Err(_) => Ok(None),
+ }
+ })?;
+ fs_tbl.set("readlink", readlink_fn)?;
+
let expand_fn = self.lua.create_function(|_lua, path: String| {
Ok(lua_expand_path(&path).to_string_lossy().to_string())
})?;
@@ -1085,6 +1135,21 @@ impl LuaEngine {
bread.set("fs", fs_tbl)?;
+ // bread.json — for parsing output from things like `kitty @ ls` or
+ // any other JSON-emitting CLI invoked via bread.exec_capture. Uses
+ // the same null-handling as every other JSON entry point into Lua
+ // (json_to_lua, not a bare to_value) so `nil` behaves as Lua nil,
+ // not a sentinel.
+ let json_tbl = self.lua.create_table()?;
+ let decode_fn = self.lua.create_function(|lua, s: String| {
+ match serde_json::from_str::(&s) {
+ Ok(v) => json_to_lua(lua, &v).map(Some).or(Ok(None)),
+ Err(_) => Ok(None),
+ }
+ })?;
+ json_tbl.set("decode", decode_fn)?;
+ bread.set("json", json_tbl)?;
+
// bread.bluetooth — BlueZ control
let bluetooth_tbl = self.lua.create_table()?;
diff --git a/examples/modules/README.md b/examples/modules/README.md
index 05eaa67..0dc9439 100644
--- a/examples/modules/README.md
+++ b/examples/modules/README.md
@@ -26,6 +26,7 @@ bread reload
| `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none |
| `focus-mode-widget.lua` | Click-to-toggle "Focus" profile that mutes audio; a widget as an action launcher, not just a readout, and stays in sync with profile changes triggered elsewhere. | none (needs `wpctl`) |
| `workflow-status-widget.lua` | Surfaces `bread.workflow.list()` in breadbar's tray — shows whichever workflow (e.g. `dock-workflow.lua`, below) is currently running or failed, hidden otherwise. | none |
+| `git-branch-widget.lua` | Shows the repo + branch of whichever git repo the focused kitty tab is sitting in; yellow when dirty. Entirely self-contained in Lua — no adapter behind it. | kitty remote control (see the module's header comment) |
Each module is the standard skeleton — `bread.module{...}`, an `on_load` that
registers subscriptions, `return M` — so they double as references for writing
diff --git a/examples/modules/git-branch-widget.lua b/examples/modules/git-branch-widget.lua
new file mode 100644
index 0000000..8e971ba
--- /dev/null
+++ b/examples/modules/git-branch-widget.lua
@@ -0,0 +1,154 @@
+-- git-branch-widget — shows " " for whichever git repo the
+-- currently focused terminal's *active tab* is sitting in, yellow when the
+-- worktree is dirty. Hides entirely when focus isn't on a terminal, or the
+-- focused tab isn't inside a git repo.
+--
+-- This is a plain Lua module doing its own OS-level legwork end to end —
+-- no dedicated Rust adapter behind it. It combines four general-purpose
+-- primitives that all already exist (or were added alongside this module
+-- as small, non-kitty-specific additions): bread.hyprland.active_window()
+-- for the focused window's class + pid, bread.fs.exists to probe for a
+-- listening socket, bread.exec_capture to run `kitty @ ls` and read its
+-- output, and bread.json.decode to parse it.
+--
+-- Why kitty remote control instead of /proc: a kitty *window* can host
+-- several *tabs*, each a separate child shell process, and the kernel has
+-- no notion of "which pty is currently displayed" — that's purely internal
+-- kitty state. Walking /proc can find the window's child processes but
+-- can't tell which one you're actually looking at. Kitty's own `kitty @ ls`
+-- tracks focus precisely at the OS-window/tab/window level, so asking it
+-- directly is the only way to get this exactly right for multi-tab windows.
+--
+-- Prerequisite — add to ~/.config/kitty/kitty.conf:
+-- allow_remote_control socket-only
+-- listen_on unix:/tmp/kitty-bread-{kitty_pid}
+-- `{kitty_pid}` makes the socket path unique per kitty process, so this
+-- works whether or not you run kitty in single-instance mode, and however
+-- many separate kitty processes you have open — this module derives the
+-- exact socket to ask from the focused window's own pid. Kitty only picks
+-- up `listen_on` on (re)start, not a config reload, so existing kitty
+-- windows need to be restarted once after adding this.
+--
+-- Drop-in: copy into ~/.config/bread/modules/. Needs `git` and the kitty
+-- remote-control config above. Assumes the terminal's WM_CLASS is "kitty"
+-- (edit TERMINAL_CLASS below for another terminal, if it has an equivalent
+-- remote-control/introspection story).
+
+local M = bread.module({ name = "git-branch-widget", version = "1.0.0" })
+
+local TERMINAL_CLASS = "kitty"
+
+local function shell_quote(s)
+ return "'" .. s:gsub("'", "'\\''") .. "'"
+end
+
+-- The exact cwd of the focused tab in the focused kitty window, or nil if
+-- focus isn't on kitty, that kitty process hasn't been restarted since the
+-- listen_on config was added, or nothing came back focused (shouldn't
+-- happen for a window Hyprland itself says is focused, but `kitty @ ls`
+-- reflects kitty's own state, not Hyprland's, so treat it as fallible).
+local function focused_tab_cwd()
+ local win = bread.hyprland.active_window()
+ if type(win) ~= "table" or win.class ~= TERMINAL_CLASS or not win.pid then
+ return nil
+ end
+
+ local socket_path = "/tmp/kitty-bread-" .. win.pid
+ if not bread.fs.exists(socket_path) then
+ return nil
+ end
+
+ local ok, output = bread.exec_capture("kitty @ --to unix:" .. socket_path .. " ls")
+ if not ok then
+ return nil
+ end
+
+ local os_windows = bread.json.decode(output)
+ if type(os_windows) ~= "table" then
+ return nil
+ end
+
+ for _, osw in ipairs(os_windows) do
+ if osw.is_focused then
+ for _, tab in ipairs(osw.tabs or {}) do
+ if tab.is_focused then
+ for _, w in ipairs(tab.windows or {}) do
+ if w.is_focused then
+ return w.cwd
+ end
+ end
+ end
+ end
+ end
+ end
+
+ return nil
+end
+
+local function git_info(cwd)
+ local quoted = shell_quote(cwd)
+
+ local ok, toplevel = bread.exec_capture("git -C " .. quoted .. " rev-parse --show-toplevel")
+ if not ok then
+ return nil
+ end
+ toplevel = toplevel:gsub("%s+$", "")
+ local repo = toplevel:match("([^/]+)/?$") or toplevel
+
+ local repo_quoted = shell_quote(toplevel)
+ local branch_ok, branch = bread.exec_capture("git -C " .. repo_quoted .. " rev-parse --abbrev-ref HEAD")
+ if not branch_ok then
+ return nil
+ end
+ branch = branch:gsub("%s+$", "")
+
+ local _, status = bread.exec_capture("git -C " .. repo_quoted .. " status --porcelain")
+ local dirty = status:match("%S") ~= nil
+
+ return { repo = repo, branch = branch, dirty = dirty }
+end
+
+local function widget_root(info)
+ if not info then
+ return { type = "label", text = "" }
+ end
+ return {
+ type = "label",
+ text = info.repo .. " " .. info.branch,
+ style = { color = info.dirty and "yellow" or "dim" },
+ }
+end
+
+local function update()
+ local cwd = focused_tab_cwd()
+ local info = cwd and git_info(cwd) or nil
+
+ bread.widget.update("branch", {
+ visible = info ~= nil,
+ tooltip = info and ("git: " .. info.repo .. "@" .. info.branch .. (info.dirty and " (dirty)" or "")) or "",
+ root = widget_root(info),
+ })
+end
+
+function M.on_load()
+ local cwd = focused_tab_cwd()
+ local info = cwd and git_info(cwd) or nil
+
+ bread.widget.register({
+ id = "branch",
+ placement = "right_of_clock",
+ visible = info ~= nil,
+ tooltip = info and ("git: " .. info.repo .. "@" .. info.branch) or "",
+ root = widget_root(info),
+ })
+
+ -- Event-driven for instant updates on focus change, plus a poll to
+ -- catch a branch switch inside the same still-focused tab (e.g. `git
+ -- checkout` run without ever changing window focus), which produces no
+ -- focus event at all.
+ bread.on("bread.window.focused", update)
+ bread.on("bread.window.focus.changed", update)
+ bread.every(2000, update)
+end
+
+return M