Skip to content
Draft
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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ uv tool install --force --reinstall . # Install globally (re-run after
```sh
export FUJIMOTO_WORKTREE_ROOT=~/git/worktrees/ # Where worktrees are created
export FUJIMOTO_GIT_ROOT=~/git/ # Optional: enables project switching
export FUJIMOTO_SKIP_NOTIFICATIONS=1 # Optional: disable background notifications
```

## Prerequisites

- Python 3.11+
- tmux (auto-installs via brew if missing)
- terminal-notifier (auto-installs via brew if missing, skippable)
- git

## Project Structure
Expand All @@ -32,6 +34,7 @@ src/fujimoto/
├── cli.py # Textual TUI app, entry point (main()), all UI screens and event handlers
├── config.py # Environment variable loading, path construction, session metadata
├── git.py # Git subprocess wrappers (worktree lifecycle, branch operations)
├── monitor.py # Background session monitor with macOS notifications
├── terminal.py # Open native terminal windows (iTerm2 with Terminal.app fallback)
├── vscode.py # Open directories in VS Code via the `code` CLI
├── tmux.py # tmux session lifecycle (create, attach, kill, list, install)
Expand Down Expand Up @@ -99,6 +102,19 @@ src/fujimoto/
- `delete_branch(branch, remote)` — `git branch -D`, optionally remote
- `cherry_pick_branch(branch, onto)` — cherry-picks commit range onto target

**`monitor.py`** — Background session monitor with macOS notifications:
- `SessionMonitor` — background thread that polls Claude JSONL logs during tmux attachment
- `_poll_once(paths, snapshot, attached_path)` — single poll cycle: detects state transitions, sends notifications
- `_send_notification(title, message)` — macOS notification via `terminal-notifier`
- `_state_display(state)` — human-readable label for session states
- `is_notifier_installed()` / `install_notifier()` — detection and brew install of `terminal-notifier`
- `notifications_skipped()` — checks `FUJIMOTO_SKIP_NOTIFICATIONS` env var
- `NotifierError` — raised on install failure
- Monitors all project sessions while user is attached to a tmux session
- Skips the currently-attached session (user can see it directly)
- Notifies on `WAITING_FOR_TOOL_APPROVAL` transitions via macOS Notification Center
- Notifications include Claude's explanation text for context

**`terminal.py`** — Open native terminal windows in a session's directory:
- `open_terminal(directory)` — opens iTerm2 if installed, otherwise Terminal.app. Raises `OSError` on non-macOS.
- `_has_iterm()` — checks for `/Applications/iTerm.app`
Expand Down Expand Up @@ -168,6 +184,7 @@ Three custom exception types, all caught in `main()`:
- **Per-session tmux config**: Prefix remapped to Ctrl+A, status bar with shortcut hints — all set via `tmux set-option -t` so the user's global config is untouched.
- **Global install via `uv tool`**: Requires `--force --reinstall` to rebuild the wheel from source. Plain `--force` reuses cached builds.
- **Session metadata**: `.fujimoto-meta.json` stored in worktree directory records the base branch for cherry-pick targeting.
- **Background session monitor**: While the user is attached to a tmux session, a `SessionMonitor` daemon thread polls all project session logs every 3 seconds. When a background session transitions to `WAITING_FOR_TOOL_APPROVAL`, a macOS notification is sent via `terminal-notifier` with Claude's explanation text for context. The monitor builds an initial snapshot without notifying to avoid false alerts on startup, and skips the currently-attached session. The thread is started before `tmux attach` and stopped (via `Event.set()`) after detach. Only runs when `terminal-notifier` is installed; skippable via `FUJIMOTO_SKIP_NOTIFICATIONS=1`.
- **Background PR creation**: Uses `claude -p --allowedTools "Bash(git:*) Bash(gh:*)"` in a tmux session for unattended PR creation.
- **Claude session integration**: The home screen fetches Claude session state from `~/.claude/projects/` JSONL logs via the log parser. Session states: 👀 awaiting input (`WAITING_FOR_USER`), 🛡️ approve tool (`WAITING_FOR_TOOL_APPROVAL`), ⚙ working (`WORKING`), 💤 idle (`IDLE`), no indicator (`UNKNOWN`). State logic: `last-prompt` marker → `IDLE` (session ended). For assistant entries: `stop_reason=tool_use` without a following `tool_result` → `WAITING_FOR_TOOL_APPROVAL` (pending user approval), `stop_reason=tool_use` with `tool_result` → `WORKING`, any other stop reason or no stop reason → `WAITING_FOR_USER`. Last entry is user → `WORKING`. Previous Claude sessions (from the project root, capped at 5) appear as resumable items. Resuming launches `claude --resume SESSION_ID` in a new tmux session. The latest Claude session per path is "claimed" by the corresponding tmux/worktree item to avoid duplication.
- **Live polling**: The home screen uses `set_interval(3s)` to poll Claude JSONL logs for state changes. When a session's state changes, labels are updated in-place via `label.update()` — the screen is never cleared or rebuilt, which avoids blank-screen flicker. A snapshot dict (`path → (session_id, state)`) is compared each tick to detect changes efficiently. The timer is stopped when navigating away (`_clear_main` cancels it) and restarted by `_show_home`.
Expand Down
58 changes: 58 additions & 0 deletions src/fujimoto/claude/log_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class ClaudeSession:
cwd: Path
git_branch: str | None
last_activity: datetime
pending_tool_summary: str | None = None

@property
def is_active(self) -> bool:
Expand All @@ -91,6 +92,45 @@ def is_active(self) -> bool:
)


def _extract_tool_summary(
entry: dict,
preceding_text: str | None,
) -> str | None:
"""Extract a human-readable summary of the pending tool use from an assistant entry.

Uses the preceding assistant text (Claude's explanation) as the primary summary,
falling back to a tool name + detail description.
"""
if preceding_text:
if len(preceding_text) > 120:
preceding_text = preceding_text[:117] + "..."
return preceding_text

# Fall back to tool name + detail if no preceding text
content = entry.get("message", {}).get("content", [])
if not isinstance(content, list):
return None

for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
name = block.get("name", "Unknown")
inp = block.get("input", {})

if name == "Bash" and "command" in inp:
cmd = inp["command"]
if len(cmd) > 80:
cmd = cmd[:77] + "..."
return f"Bash: {cmd}"
if name in ("Edit", "Write") and "file_path" in inp:
return f"{name}: {Path(inp['file_path']).name}"
if name == "Read" and "file_path" in inp:
return f"Read: {Path(inp['file_path']).name}"
return name

return None


def encode_project_path(path: Path) -> str:
"""Encode a project path for use as a Claude projects directory name.

Expand Down Expand Up @@ -135,6 +175,7 @@ def parse_session(jsonl_path: Path) -> ClaudeSession:
last_any: dict | None = None
session_ended = False
tool_result_after_last_tool_use = False
last_assistant_text: str | None = None

for line in text.splitlines():
line = line.strip()
Expand Down Expand Up @@ -164,6 +205,16 @@ def parse_session(jsonl_path: Path) -> ClaudeSession:
if entry_type in (EntryType.ASSISTANT, EntryType.USER):
last_meaningful = entry

# Track assistant text for tool approval context
if entry_type == EntryType.ASSISTANT:
content = entry.get("message", {}).get("content", [])
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_val = block.get("text", "").strip()
if text_val:
last_assistant_text = text_val

# Track whether a tool_result follows the most recent tool_use
if entry_type == EntryType.ASSISTANT:
raw_stop = entry.get("message", {}).get("stop_reason")
Expand Down Expand Up @@ -222,6 +273,12 @@ def parse_session(jsonl_path: Path) -> ClaudeSession:
# Last meaningful entry is USER → working
state = SessionState.WORKING

tool_summary = (
_extract_tool_summary(last_meaningful, last_assistant_text)
if state == SessionState.WAITING_FOR_TOOL_APPROVAL
else None
)

return ClaudeSession(
jsonl_path=jsonl_path,
session_id=session_id,
Expand All @@ -231,6 +288,7 @@ def parse_session(jsonl_path: Path) -> ClaudeSession:
cwd=Path(last_meaningful.get("cwd", "/")),
git_branch=last_meaningful.get("gitBranch"),
last_activity=_parse_timestamp(last_meaningful.get("timestamp", "")),
pending_tool_summary=tool_summary,
)


Expand Down
83 changes: 76 additions & 7 deletions src/fujimoto/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
push_branch,
remove_worktree,
)
from fujimoto.monitor import (
NotifierError,
SessionMonitor,
install_notifier,
is_notifier_installed,
notifications_skipped,
)
from fujimoto.terminal import open_terminal
from fujimoto.vscode import open_vscode
from fujimoto.tmux import (
Expand Down Expand Up @@ -387,6 +394,9 @@ async def on_mount(self) -> None:
if not is_tmux_installed():
await self._show_tmux_install()
return
if not notifications_skipped() and not is_notifier_installed():
await self._show_notifier_install()
return
self._init_git_info()
await self._show_home()
except (ConfigError, GitError) as e:
Expand Down Expand Up @@ -449,6 +459,27 @@ async def _show_tmux_install(self) -> None:
)
self.query_one("#tmux-install-list").focus()

async def _show_notifier_install(self) -> None:
await self._clear_main()
main = self.query_one("#main")
await main.mount(
Container(
Label("terminal-notifier is not installed", classes="form-label"),
Static(
"terminal-notifier is required for background session notifications."
),
Static(""),
ListView(
ListItem(Label("Install with brew"), id="install-notifier"),
ListItem(Label("Skip"), id="skip-notifier"),
ListItem(Label("Quit"), id="quit-app"),
id="notifier-install-list",
),
id="conflict-panel",
)
)
self.query_one("#notifier-install-list").focus()

# -- Home screen --

async def _show_home(self) -> None:
Expand Down Expand Up @@ -1447,6 +1478,24 @@ async def on_tmux_install_selected(self, event: ListView.Selected) -> None:
else:
self.exit()

@on(ListView.Selected, "#notifier-install-list")
async def on_notifier_install_selected(self, event: ListView.Selected) -> None:
if event.item.id == "install-notifier":
try:
install_notifier()
self._init_git_info()
await self._show_home()
except (NotifierError, ConfigError, GitError) as e:
await self._show_error(str(e))
elif event.item.id == "skip-notifier":
try:
self._init_git_info()
await self._show_home()
except (ConfigError, GitError) as e:
await self._show_error(str(e))
else:
self.exit()

@on(Input.Submitted, "#direct-title-input")
async def on_direct_title_submitted(self, event: Input.Submitted) -> None:
value = event.value.strip()
Expand Down Expand Up @@ -1663,13 +1712,33 @@ def main() -> None:
if resume_id
else _build_system_prompt(session_type, project_name, working_dir)
)
launch_claude_in_tmux(
project_name,
working_dir,
tmux_name,
system_prompt=system_prompt,
resume_session_id=resume_id,
)

# Start background monitor if notifications are available
monitor: SessionMonitor | None = None
if is_notifier_installed():
monitor_paths: list[Path] = []
if app._project_root:
monitor_paths.append(app._project_root)
monitor_paths.extend(app._existing_worktrees)
if working_dir not in monitor_paths:
monitor_paths.append(working_dir)
monitor = SessionMonitor(
paths=monitor_paths,
attached_path=working_dir,
)
monitor.start()

try:
launch_claude_in_tmux(
project_name,
working_dir,
tmux_name,
system_prompt=system_prompt,
resume_session_id=resume_id,
)
finally:
if monitor:
monitor.stop()
else:
break
set_terminal_title("")
Expand Down
Loading
Loading