Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# 0.12.0 (2026-03-25)

#### Added

- **Follow mode for scratch pane**: The scratch pane can now follow across window and session switches, keeping a persistent bird's-eye view of lemon sessions. Enable with `lemonaid tmux scratch --follow`. Uses shell-based tmux hooks (~5ms per switch, no Python on the hot path). Height is preserved across switches. `prefix+l` toggles focus instead of hiding. `q` temporarily parks the pane until next `prefix+l`.
- **`scratch_height` config**: Default scratch pane height is now configurable via `scratch_height` in `[tmux-session]` (default `"10%"`). No more `--height` flag needed in tmux keybindings.
- **`follow_scratch` config**: Set `follow_scratch = true` in `[tmux-session]` to auto-enable follow for new tmux servers on first scratch pane creation.

#### Changed

- **Scratch pane state files renamed**: `scratch-pane-<server>.json` → `tmux-scratch-<server>-pane` (plain text). Legacy files are cleaned up automatically on first use.

# 0.11.0 (2026-03-24)

#### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The TUI doesn't need to be running for notifications to arrive (hooks write dire
- **Terminal integration**: Hit enter to jump directly to the waiting session's pane (supports [`tmux`](docs/tmux.md) and [WezTerm](docs/wezterm.md))
- **Session history & resume**: Browse archived sessions across all projects, filter by name/cwd/branch, and resume directly or copy the command
- **Bootstrap**: `lemonaid claude bootstrap` imports historical Claude sessions from before lemonaid was installed into the archive
- **Scratch pane** (`tmux`): Toggle an always-on inbox with a keybinding - no startup delay, auto-hides after selection
- **Scratch pane** (`tmux`): Toggle an always-on inbox with a keybinding no startup delay. Optional **follow mode** keeps the pane visible across all window/session switches
- **Auto-refresh TUI**: See new notifications appear without losing your place

### Assorted helpers
Expand Down
2 changes: 2 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ See [wezterm.md](wezterm.md).

| Key | Default | Description |
|-----|---------|-------------|
| `scratch_height` | `"10"` | Default height for the scratch pane in rows. Percentages (e.g. `"15%"`) are accepted but resize detection won't work with them. |
| `follow_scratch` | `false` | Bootstrap follow mode for new tmux servers. When the scratch pane is first toggled on a server, this determines whether follow is enabled by default. See [tmux.md](tmux.md#follow-mode). |
| `resume_window` | `0` | 0-based index into the template window list: which window to replace with the resume command when spawning a tmux session from history (`T`). Set to `1` if your lemon is in the second tab. |

### `[tmux-session.templates]`
Expand Down
78 changes: 63 additions & 15 deletions docs/tmux.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,41 +69,89 @@ Add to your `~/.tmux.conf`:
bind-key l run-shell 'lemonaid tmux scratch'
```

Reload tmux config:

```bash
tmux source-file ~/.tmux.conf
```

### Usage

1. Press `prefix + l` to show the lma inbox as a split at the top of your current window
2. Select a notification with Enter - you'll switch to that session and the lma pane auto-hides
2. Select a notification with Enter you'll switch to that session and the lma pane auto-hides
3. Press `prefix + l` again to bring it back

The keybinding is "idempotent" in that pressing it always gets you to the scratch pane:

- If the pane is hidden → show it
- If the pane is visible but not focused → select it
- If the pane is visible but not focused → focus it
- If the pane is visible and focused → hide it

### How it works

1. First toggle creates a tmux session (`_lma_scratch`) running `lma --scratch`
2. The pane is joined into your current window as a 30% top split
2. The pane is joined into your current window as a top split
3. When you select a notification, lma auto-dismisses by breaking the pane to its own window
4. Subsequent toggles show/select/hide the same pane (no restart, instant response)

State is tracked per tmux server in `~/.local/state/lemonaid/scratch-pane-<server>.json`, so multiple tmux servers won't conflict.
State is tracked per tmux server in `~/.local/state/lemonaid/`, so multiple tmux servers won't conflict.

### Options
### Follow mode

Follow mode keeps the scratch pane visible across window and session switches — it follows you everywhere, giving a persistent bird's-eye view of your lemon sessions.

#### Enabling follow

```bash
# Customize the pane height
lemonaid tmux scratch --height 40%
lemonaid tmux scratch --follow
```

# See what action was taken
lemonaid tmux scratch -v # prints: created, shown, or hidden
This enables follow for the current tmux server and prints `set-hook` lines to add to `.tmux.conf` for persistence across tmux restarts:

```tmux
set-hook -g after-select-window[100] 'run-shell -b "~/.local/state/lemonaid/tmux-scratch-follow.sh"'
set-hook -g session-window-changed[100] 'run-shell -b "~/.local/state/lemonaid/tmux-scratch-follow.sh"'
set-hook -g client-session-changed[100] 'run-shell -b "~/.local/state/lemonaid/tmux-scratch-follow.sh"'
```

The hooks run a shell script (~5ms per switch, no Python) that moves the scratch pane into your current window.

#### Behavior in follow mode

- **Switching sessions/windows**: The scratch pane follows automatically, preserving its height
- **`prefix + l`**: Toggles focus between the scratch pane and your main pane (never hides)
- **`q` in lma**: Temporarily parks the pane. The next `prefix + l` brings it back with follow still active
- **Selecting a notification**: Switches to that session; the scratch pane follows via the hook
- **Resizing**: Manual resizes are preserved across switches

#### Disabling follow

```bash
lemonaid tmux scratch --unfollow
```

This disables follow for the current tmux server session. The hooks in `.tmux.conf` remain but become no-ops. Re-run `--follow` to re-enable.

#### Config bootstrap

To have follow enabled by default for new tmux servers:

```toml
[tmux-session]
follow_scratch = true
```

When the scratch pane is first toggled on a new server, the follow state file is bootstrapped from this config value. After that, `--follow` / `--unfollow` controls the per-server state independently.

### Options

The default scratch pane height (in rows) is configurable:

```toml
[tmux-session]
scratch_height = "12"
```

In follow mode, if you manually resize the pane (drag the border), a `Save pane height` hint appears in the status bar. Press `S` to persist the new height.

The `--height` CLI flag overrides the config for a single invocation:

```bash
lemonaid tmux scratch --height 15
```

## Session Templates
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "lemonaid"
version = "0.11.0"
version = "0.12.0"
description = "Attention inbox for managing notifications from lemons and other background tools"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
9 changes: 9 additions & 0 deletions src/lemonaid/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class TmuxSessionConfig:
# 0-based index into the template window list: which window to replace
# with the resume command when spawning a session from history.
resume_window: int = 0
# Default height for the scratch pane in rows. Percentages (e.g. "15%")
# are accepted but resize detection won't work reliably with them.
scratch_height: str = "10"
# When true, the scratch pane follows across window/session switches.
follow_scratch: bool = False

def get_template(self, name: str) -> list[str] | None:
"""Get a template by name."""
Expand Down Expand Up @@ -79,6 +84,7 @@ class TuiConfig:
"""Configuration for the TUI."""

transparent: bool = False # Use ANSI colors for terminal transparency
refresh_interval: float = 0.33 # Seconds between TUI refreshes
keybindings: KeybindingsConfig = field(default_factory=KeybindingsConfig)
# Override the label shown for each backend in the TUI.
# Keys are channel prefixes (claude, codex, openclaw, opencode); values are display strings.
Expand Down Expand Up @@ -143,6 +149,8 @@ def _parse_config(data: dict[str, Any]) -> Config:
tmux_session = TmuxSessionConfig(
templates=tmux_session_data.get("templates", {}),
resume_window=tmux_session_data.get("resume_window", 0),
scratch_height=tmux_session_data.get("scratch_height", "30%"),
follow_scratch=tmux_session_data.get("follow_scratch", False),
)

tui_data = data.get("tui", {})
Expand All @@ -157,6 +165,7 @@ def _parse_config(data: dict[str, Any]) -> Config:
)
tui = TuiConfig(
transparent=tui_data.get("transparent", False),
refresh_interval=tui_data.get("refresh_interval", 0.33),
keybindings=keybindings,
backend_labels=tui_data.get("backend_labels", {}),
)
Expand Down
43 changes: 34 additions & 9 deletions src/lemonaid/inbox/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
start_unified_watcher,
)
from ...log import get_logger
from ...tmux.scratch import (
_clear_state,
_hide,
height_has_drifted,
is_follow_enabled,
save_current_height,
)
from ...tmux.session import spawn_session_for_resume
from .. import db
from .screens import RenameScreen
Expand Down Expand Up @@ -234,6 +241,9 @@ def _setup_keybindings(self) -> None:
# Patch Claude (always hidden, always 'P')
self.bind("P", "patch_claude", description="Patch Claude", show=False)

# Save scratch pane height (hidden, shown dynamically when height drifts)
self.bind("S", "save_scratch_height", description="Save Height", show=False)

# Cross-table arrow navigation (always active)
self.bind("up", "cursor_up", description="Up", show=False)
self.bind("down", "cursor_down", description="Down", show=False)
Expand Down Expand Up @@ -279,7 +289,7 @@ def on_mount(self) -> None:
self.query_one("#history_filter", Input).display = False

self._refresh_notifications()
self.set_interval(1.0, self._refresh_notifications)
self.set_interval(self.config.tui.refresh_interval, self._refresh_notifications)
# Start transcript watchers for auto-dismiss, message updates, and exit detection
start_unified_watcher(
backends=cast(
Expand Down Expand Up @@ -509,6 +519,9 @@ def _refresh_notifications(self, *, stay_on_unread: bool = False) -> None:
if self._claude_patch_status == "unpatched":
status_text += " | [bold cyan]P[/]atch Claude for faster notifications"

if self._scratch_mode and is_follow_enabled() and height_has_drifted():
status_text += " | [bold cyan]S[/]ave pane height"

status.update(status_text)

def action_quit(self) -> None:
Expand Down Expand Up @@ -682,7 +695,7 @@ def _resume_session(self, *, copy_only: bool = False) -> None:
except (subprocess.CalledProcessError, FileNotFoundError):
self.notify(f"Resume: {cmd_str}", severity="information")

if self._scratch_mode and not copy_only:
if self._scratch_mode and not copy_only and not is_follow_enabled():
self._hide_scratch_pane()

def action_copy_resume(self) -> None:
Expand Down Expand Up @@ -908,14 +921,25 @@ def action_patch_claude(self) -> None:

self._refresh_notifications()

def action_save_scratch_height(self) -> None:
"""Save the current scratch pane height for follow mode."""
if not self._scratch_mode:
return

save_current_height()
self.notify("Pane height saved")
self._refresh_notifications()

def _hide_scratch_pane(self) -> None:
"""Hide this pane by breaking it to a new window (for scratch mode)."""
"""Hide the scratch pane back to its holding session.

Clears the pane state file so follow hooks become no-ops until
the user re-opens with prefix+l (which rewrites the state file).
"""
pane_id = os.environ.get("TMUX_PANE")
if pane_id:
subprocess.run(
["tmux", "break-pane", "-d", "-s", pane_id],
capture_output=True,
)
_clear_state()
_hide(pane_id)

def _get_active_for_watcher(
self,
Expand Down Expand Up @@ -1001,8 +1025,9 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.config,
switch_source=notification.switch_source,
)
# In scratch/auto-dismiss mode, hide the pane after navigation
if self._scratch_mode:
# In scratch mode, hide the pane after navigation — unless
# follow mode is active, in which case the hook will re-show it.
if self._scratch_mode and not is_follow_enabled():
self._hide_scratch_pane()


Expand Down
38 changes: 33 additions & 5 deletions src/lemonaid/tmux/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from ..config import load_config
from .navigation import go_back, swap_back_location
from .scratch import toggle_scratch
from .scratch import ensure_scratch, set_follow, toggle_scratch
from .session import create_session


Expand All @@ -32,8 +32,16 @@ def cmd_swap(args: argparse.Namespace) -> None:

def cmd_scratch(args: argparse.Namespace) -> None:
"""Toggle the scratch lma pane."""
result = toggle_scratch(height=args.height)
# Optionally print result for debugging
config = load_config()
height = args.height or config.tmux_session.scratch_height

if args.follow is not None:
result = set_follow(height=height, enable=args.follow)
elif args.ensure:
result = ensure_scratch(height=height)
else:
result = toggle_scratch(height=height, follow_default=config.tmux_session.follow_scratch)

if args.verbose:
print(result)

Expand Down Expand Up @@ -121,8 +129,28 @@ def setup_parser(subparsers: argparse._SubParsersAction) -> None:
)
scratch_parser.add_argument(
"--height",
default="30%",
help="Height of the scratch pane when shown (default: 30%%)",
default=None,
help="Height of the scratch pane (default: from config, or 10%%)",
)
scratch_parser.add_argument(
"--ensure",
action="store_true",
help="Show the scratch pane if hidden, but never hide it (for hooks)",
)
follow_group = scratch_parser.add_mutually_exclusive_group()
follow_group.add_argument(
"--follow",
action="store_const",
const=True,
default=None,
help="Keep the scratch pane visible across all windows (installs tmux hooks)",
)
follow_group.add_argument(
"--unfollow",
dest="follow",
action="store_const",
const=False,
help="Stop following across windows (removes tmux hooks)",
)
scratch_parser.add_argument(
"-v",
Expand Down
Loading