Skip to content
Merged
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
151 changes: 56 additions & 95 deletions forestui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,32 @@

from forestui import __version__
from forestui.components.messages import (
ConfigureClaudeCommand,
ContinueClaudeCustomSession,
ContinueClaudeSession,
ContinueClaudeYoloSession,
OpenInEditor,
OpenInFileManager,
OpenInTerminal,
StartClaudeCustomSession,
StartClaudeSession,
StartClaudeYoloSession,
)
from forestui.components.modals import (
AddRepositoryModal,
AddWorktreeModal,
ClaudeCommandModal,
ConfirmDeleteModal,
CreateWorktreeFromIssueModal,
SettingsModal,
)
from forestui.components.repository_detail import RepositoryDetail
from forestui.components.sidebar import Sidebar
from forestui.components.worktree_detail import WorktreeDetail
from forestui.models import ClaudeCommandResult, GitHubIssue, Repository, Worktree
from forestui.models import (
CustomClaudeButton,
GitHubIssue,
Repository,
Worktree,
)
from forestui.services.claude_session import get_claude_session_service
from forestui.services.git import GitError, get_git_service
from forestui.services.github import get_github_service
Expand Down Expand Up @@ -278,6 +283,7 @@ async def _refresh_detail_pane(self) -> None:
commit_hash=commit_hash,
commit_time=commit_time,
has_remote=has_remote,
custom_buttons=self._settings_service.settings.custom_buttons,
)
)
# Fetch sessions in background
Expand Down Expand Up @@ -314,6 +320,7 @@ async def _refresh_detail_pane(self) -> None:
commit_hash=commit_hash,
commit_time=commit_time,
has_remote=has_remote,
custom_buttons=self._settings_service.settings.custom_buttons,
)
await detail_pane.mount(detail)
# Start spinner and fetch sessions and issues in background
Expand Down Expand Up @@ -380,6 +387,16 @@ def on_continue_claude_yolo_session(self, event: ContinueClaudeYoloSession) -> N
"""Handle continue Claude YOLO session request."""
self._continue_claude_session(event.session_id, event.path, yolo=True)

def on_start_claude_custom_session(self, event: StartClaudeCustomSession) -> None:
"""Handle start Claude custom-button session request."""
self._start_claude_custom_session(event.path, event.button)

def on_continue_claude_custom_session(
self, event: ContinueClaudeCustomSession
) -> None:
"""Handle continue Claude custom-button session request."""
self._continue_claude_custom_session(event.session_id, event.path, event.button)

@work
async def on_worktree_detail_sync_requested(
self, event: WorktreeDetail.SyncRequested
Expand All @@ -399,10 +416,6 @@ async def on_repository_detail_add_worktree_requested(
"""Handle add worktree request."""
await self._show_add_worktree_modal(event.repo_id)

def on_configure_claude_command(self, event: ConfigureClaudeCommand) -> None:
"""Handle configure Claude command request."""
self._show_claude_command_modal(event.repo_id, event.worktree_id)

@work
async def on_repository_detail_remove_repository_requested(
self, event: RepositoryDetail.RemoveRepositoryRequested
Expand Down Expand Up @@ -820,6 +833,8 @@ async def action_open_settings(self) -> None:
if result:
self._settings_service.save_settings(result)
self.notify("Settings saved")
# Re-render so custom button changes take effect
await self._refresh_detail_pane()

async def action_refresh(self) -> None:
"""Refresh the UI."""
Expand Down Expand Up @@ -913,95 +928,10 @@ def _open_in_file_manager(self, path: str) -> None:
else:
self.notify("Failed to create mc window", severity="error")

@work
async def _show_claude_command_modal(
self, repo_id: UUID, worktree_id: UUID | None = None
) -> None:
"""Show the Claude command configuration modal."""
repo = self._state.find_repository(repo_id)
if not repo:
return

# Determine if configuring worktree or repository
if worktree_id:
worktree = repo.find_worktree(worktree_id)
if not worktree:
return
name = f"{repo.name}:{worktree.name}"
current_command = worktree.custom_claude_command
is_worktree = True
else:
name = repo.name
current_command = repo.custom_claude_command
is_worktree = False

result: ClaudeCommandResult = await self.push_screen_wait(
ClaudeCommandModal(name, current_command, is_worktree=is_worktree)
)

if result.cancelled:
return

# Update the appropriate level
if worktree_id:
self._state.update_worktree_command(worktree_id, result.command)
target = f"{repo.name}:{worktree.name}" # type: ignore[union-attr]
else:
self._state.update_repository_command(repo_id, result.command)
target = repo.name

await self._refresh_detail_pane()
if result.command:
self.notify(f"Custom Claude command set for {target}")
else:
self.notify(f"Custom Claude command cleared for {target}")

def _find_repo_for_path(self, path: str) -> Repository | None:
"""Find the repository associated with a path (worktree or source)."""
for repo in self._state.repositories:
if repo.source_path == path:
return repo
for worktree in repo.worktrees:
if worktree.path == path:
return repo
return None

def _resolve_claude_command(self, path: str) -> str | None:
"""Resolve Claude command with hierarchy: worktree > repo > folder > default.

Args:
path: The path to check for associated repository/worktree

Returns:
Custom command if set, None to use default "claude"
"""
# Check worktree-level and repo-level
for repo in self._state.repositories:
# Check worktrees first (most specific)
for worktree in repo.worktrees:
if worktree.path == path:
if worktree.custom_claude_command:
return worktree.custom_claude_command
if repo.custom_claude_command:
return repo.custom_claude_command
break
# Check repository source path
if repo.source_path == path:
if repo.custom_claude_command:
return repo.custom_claude_command
break

# Fall back to folder-level setting
settings = self._settings_service.settings
return settings.custom_claude_command

def _start_claude_session(self, path: str, yolo: bool = False) -> None:
"""Start a new Claude session in a tmux window."""
name = self._get_tmux_window_name(path)
custom_command = self._resolve_claude_command(path)
window_name = self._tmux_service.create_claude_window(
name, path, yolo=yolo, custom_command=custom_command
)
window_name = self._tmux_service.create_claude_window(name, path, yolo=yolo)
if window_name:
mode = " (YOLO)" if yolo else ""
self.notify(f"Started Claude{mode} in {window_name}")
Expand All @@ -1013,20 +943,51 @@ def _continue_claude_session(
) -> None:
"""Continue an existing Claude session in a tmux window."""
name = self._get_tmux_window_name(path)
custom_command = self._resolve_claude_command(path)
window_name = self._tmux_service.create_claude_window(
name,
path,
resume_session_id=session_id,
yolo=yolo,
custom_command=custom_command,
)
if window_name:
mode = " (YOLO)" if yolo else ""
self.notify(f"Resuming Claude{mode} in {window_name}")
else:
self.notify("Failed to create Claude window", severity="error")

def _start_claude_custom_session(
self, path: str, button: CustomClaudeButton
) -> None:
"""Start a Claude session using a custom button's command and prefix."""
name = self._get_tmux_window_name(path)
window_name = self._tmux_service.create_claude_window(
name,
path,
custom_command=button.command,
custom_prefix=button.prefix,
)
if window_name:
self.notify(f"Started Claude ({button.label}) in {window_name}")
else:
self.notify("Failed to create Claude window", severity="error")

def _continue_claude_custom_session(
self, session_id: str, path: str, button: CustomClaudeButton
) -> None:
"""Continue a Claude session using a custom button's command and prefix."""
name = self._get_tmux_window_name(path)
window_name = self._tmux_service.create_claude_window(
name,
path,
resume_session_id=session_id,
custom_command=button.command,
custom_prefix=button.prefix,
)
if window_name:
self.notify(f"Resuming Claude ({button.label}) in {window_name}")
else:
self.notify("Failed to create Claude window", severity="error")

async def _import_existing_worktrees(self, repo: Repository) -> None:
"""Import existing worktrees from a repository."""
try:
Expand Down
28 changes: 17 additions & 11 deletions forestui/components/messages.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Shared message classes for detail view components."""

from uuid import UUID

from textual.message import Message

from forestui.models import CustomClaudeButton


class OpenInEditor(Message):
"""Request to open a path in editor."""
Expand Down Expand Up @@ -63,14 +63,20 @@ def __init__(self, session_id: str, path: str) -> None:
super().__init__()


class ConfigureClaudeCommand(Message):
"""Request to configure custom Claude command for a repository or worktree."""
class StartClaudeCustomSession(Message):
"""Request to start a new Claude session using a custom button."""

def __init__(self, path: str, button: CustomClaudeButton) -> None:
self.path = path
self.button = button
super().__init__()

def __init__(
self,
repo_id: UUID,
worktree_id: UUID | None = None,
) -> None:
self.repo_id = repo_id
self.worktree_id = worktree_id

class ContinueClaudeCustomSession(Message):
"""Request to continue an existing Claude session using a custom button."""

def __init__(self, session_id: str, path: str, button: CustomClaudeButton) -> None:
self.session_id = session_id
self.path = path
self.button = button
super().__init__()
Loading