diff --git a/CLAUDE.md b/CLAUDE.md index 8b362aa..b3c210b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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()` diff --git a/src/fujimoto/cli.py b/src/fujimoto/cli.py index 5ba5158..ac391a8 100644 --- a/src/fujimoto/cli.py +++ b/src/fujimoto/cli.py @@ -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") @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index efef58f..e37031b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 @@ -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 @@ -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: @@ -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