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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Otherwise it:
- Home screen sections: actions ("New worktree session", "New session in X", "Ad hoc session"), active sessions (with Claude state indicators), inactive worktrees (with Claude state), previous Claude sessions (resumable, capped at 5), switch project
- Worktree create flow: title → branch select (default w/ fetch & rebase, current branch, another branch → picker) → create
- Session actions submenu (in order): for active sessions, Connect → Resume previous session; for inactive worktrees, Resume previous session → Launch (resume is the more common action when picking an idle worktree). Then: Open terminal, Open in VS Code, Rename, Terminate session (active only), Finish (worktree only), Cancel. Claude-session items show just "Resume" + Open terminal/VS Code + Cancel.
- "Resume previous session" auto-launches the sole candidate when only one previous Claude session exists for the path, skipping the picker. Two or more sessions still show the picker.
- Finish flow: Push & Create PR (background Claude), Cherry-pick to base, Discard & Delete
- Open terminal flow: sub-menu with "This window" (default; uses Textual's `App.suspend()` to pause the TUI, then runs `subprocess.run([$SHELL], cwd=session.path)` as a child process — when the user types `exit`, the TUI resumes on the session actions menu) and "New window" (spawns a new iTerm/Terminal/Linux emulator window via `open_terminal()`)
- All view transitions are `async` — `await _clear_main()` then `await mount()`
Expand Down
43 changes: 25 additions & 18 deletions src/fujimoto/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,11 +925,35 @@ async def _show_session_actions(self, session: SessionInfo) -> None:

# -- Resume session picker --

def _launch_resume(self, session: SessionInfo, cs: ClaudeSession) -> None:
# For inactive worktrees, reuse the worktree's session name so the resumed
# session stays identified as a worktree item on the next TUI view (correct
# path and session lookup). For active worktrees the session name is in use,
# so a new direct-N name is needed.
if session.session_type == "worktree" and not session.is_active:
tmux_name = session.tmux_session
else:
tmux_name = get_next_direct_session_name(
session.project, self._active_sessions
)
self._launch_target = (
session.project,
cs.cwd, # authoritative original directory from the session log
tmux_name,
session.session_type,
cs.session_id,
)
self.exit()

async def _show_resume_session_picker(self, session: SessionInfo) -> None:
self._selected_session = session
sessions = get_sessions_for_path(session.path)
self._resume_sessions = sessions

if len(sessions) == 1:
self._launch_resume(session, sessions[0])
return

await self._clear_main()
main = self.query_one("#main")

Expand Down Expand Up @@ -1598,24 +1622,7 @@ async def on_resume_picker_selected(self, event: ListView.Selected) -> None:
return
idx = int(item_id.split("-", 1)[1])
cs = self._resume_sessions[idx]
# For inactive worktrees, reuse the worktree's session name so the resumed
# session stays identified as a worktree item on the next TUI view (correct
# path and session lookup). For active worktrees the session name is in use,
# so a new direct-N name is needed.
if session.session_type == "worktree" and not session.is_active:
tmux_name = session.tmux_session
else:
tmux_name = get_next_direct_session_name(
session.project, self._active_sessions
)
self._launch_target = (
session.project,
cs.cwd, # authoritative original directory from the session log
tmux_name,
session.session_type,
cs.session_id,
)
self.exit()
self._launch_resume(session, cs)

@on(ListView.Selected, "#confirm-list")
async def on_confirm_selected(self, event: ListView.Selected) -> None:
Expand Down
39 changes: 25 additions & 14 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,15 +794,12 @@ async def test_resume_picker_sets_launch_target(self, tmp_path: Path) -> None:
break
await pilot.press("enter")
await pilot.pause()
# "Resume previous session" is the second option (after Connect)
# "Resume previous session" is the second option (after Connect).
# With a single previous session, the picker is skipped and the
# session launches directly.
await pilot.press("down")
await pilot.press("enter")
await pilot.pause()
# Should now show the resume picker
assert len(app.query("#resume-picker")) > 0
# Select the first session
await pilot.press("enter")
await pilot.pause()
assert app._launch_target is not None
assert app._launch_target[4] == fake_session.session_id
# Active worktree → direct-N name, not the worktree name
Expand Down Expand Up @@ -841,10 +838,8 @@ async def test_resume_picker_inactive_worktree_uses_worktree_session_name(
break
await pilot.press("enter")
await pilot.pause()
# "Resume previous session" is the first option for inactive worktrees
await pilot.press("enter")
await pilot.pause()
assert len(app.query("#resume-picker")) > 0
# "Resume previous session" is the first option for inactive worktrees.
# Single previous session → auto-launch, no picker.
await pilot.press("enter")
await pilot.pause()
assert app._launch_target is not None
Expand All @@ -869,10 +864,22 @@ async def test_resume_picker_cancel_returns_home(self, tmp_path: Path) -> None:
title=None,
first_prompt=None,
)
fake_session_2 = ClaudeSession(
jsonl_path=wt / "session-2.jsonl",
session_id="bcd23456-ef78-9012-bcde-f23456789012",
state=SessionState.IDLE,
last_entry_type=EntryType.ASSISTANT,
stop_reason=StopReason.END_TURN,
cwd=wt,
git_branch="worktree/20260309-test",
last_activity=datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc),
title=None,
first_prompt=None,
)
with _patch_git_info(
sessions=["test-proj/20260309-test"],
worktrees=[wt],
claude_sessions_fn=lambda _path: [fake_session],
claude_sessions_fn=lambda _path: [fake_session, fake_session_2],
):
app = SessionApp()
async with app.run_test() as pilot:
Expand All @@ -886,10 +893,14 @@ async def test_resume_picker_cancel_returns_home(self, tmp_path: Path) -> None:
await pilot.press("down")
await pilot.press("enter")
await pilot.pause()
# Shows empty state + cancel
# Two sessions → picker shown
assert len(app.query("#resume-picker")) > 0
# Navigate to cancel and press enter
await pilot.press("down")
# Navigate to cancel (past both sessions) and press enter
picker = app.query_one("#resume-picker", ListView)
for i, item in enumerate(picker.children):
if item.id == "rp-cancel":
picker.index = i
break
await pilot.press("enter")
await pilot.pause()
assert len(app.query("#home-list")) > 0
Expand Down
Loading