From 544c68f120a38459d7727c702e62287ac3baf52d Mon Sep 17 00:00:00 2001 From: Jon Grace-Cox <30441316+jongracecox@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:40:37 -0600 Subject: [PATCH] feat(cli): park a session to set it aside MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping a session is the right move for a break, but not for work you are consciously shelving: the row goes orange alongside everything a restart took away, and "Restore N stopped sessions" sweeps it back up with the rest. Park is the same operation with a flag. `SessionRecord.parked` makes it a flavour of stopped rather than a third intent, so everything keyed off `is_stopped` — resume, terminate, the sessions section — keeps working; only the icon (blue P) and the restore sweep change. `mark_open` writes a fresh record, so launching or resuming un-parks. The sessions section now reads running, then parked, then stopped, and the `Ctrl-A x` prompt gains a Park option beside Stop. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 28 +++-- CONTRIBUTING.md | 10 +- README.md | 26 +++-- src/fujimoto/cli.py | 90 +++++++++++---- src/fujimoto/session_state.py | 43 ++++++- tests/test_cli.py | 208 +++++++++++++++++++++++++++++++++- 6 files changed, 358 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dab5b62..bab70ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,12 +151,21 @@ conflate them: | intent | tmux | icon | home-screen section | |---|---|---|---| | open | running | 🟢 | sessions | +| open, parked | stopped | 🅿️ | sessions | | open | stopped | 🟠 | sessions | | closed / no record | stopped | ⚫ | inactive worktrees | +Within the *sessions* section rows are ordered running, parked, then stopped. + **Stop** ends the claude process and keeps the record open (the transcript is untouched, so the conversation resumes; an in-flight task is interrupted). -**Terminate** ends it and deletes the record. Anything that kills a session +**Park** is the same operation with `SessionRecord.parked` set: a *flavour* of +stopped, not a third intent, so everything keyed off `is_stopped` (resume, +terminate, the sessions section) keeps working. It differs only in the icon and +in being skipped by **Restore N stopped sessions** — a session put down on +purpose should not come back with the ones a crash took away. `mark_open` +writes a fresh record, so launching or resuming un-parks; that is the only exit +short of terminating. **Terminate** ends it and deletes the record. Anything that kills a session *outside* fujimoto — host restart, `tmux kill-session`, a closed window, `exit` in the pane — leaves the record alone, so it comes back as stopped. That is the whole restart-recovery mechanism: no boot-time detection, no heuristics. @@ -236,14 +245,17 @@ as JSON in `~/.cache/fujimoto/sessions.json` (same graceful-degradation pattern as `settings.py`: missing file, unreadable cache or corrupt JSON yield an empty state, never an error): - `SessionRecord` dataclass: `cwd`, `project`, `session_type`, `branch`, - `claude_session_id`, `last_seen`. Only `cwd` is required — every other field - defaults, so a record written by a different fujimoto version still loads. + `claude_session_id`, `parked`, `last_seen`. Only `cwd` is required — every + other field defaults, so a record written by a different fujimoto version + still loads (`parked` is coerced with `bool()` on load for exactly that + reason). - Keyed by **tmux session name**, so worktree, direct and ad hoc sessions are covered uniformly and a record outlives its worktree directory. - `load_state()` / `save_state()`, `mark_open(...)` (every launch and reconnect; a reconnect passing no id keeps the id recorded at first launch), - `mark_closed(name)`, `touch(name, claude_session_id=None)`, - `rename(old, new)`, `prune()`. + `mark_closed(name)`, `touch(name, claude_session_id=None, *, parked=None)` + (`parked` is tri-state: `None` leaves the flag alone, so a plain stop of a + parked session doesn't silently un-park it), `rename(old, new)`, `prune()`. - **A record's presence means "open"; its absence means "closed"** — which is also what a session fujimoto has never launched looks like. `mark_closed` therefore just deletes the record, and there is no reconciliation pass and @@ -407,7 +419,7 @@ responsible for running it off the event loop. - `SessionApp` — main app class with CSS styling - Module-level helpers: `_claude_state_label(state)`, `_relative_time(dt)`, `_get_claude_sessions(root, worktrees)`, `_is_fork_worktree(path)`, `_build_fork_system_prompt(project, working_dir, parent_worktree, base_branch)`, `_fit_snippet(snippet, max_width)` / `_render_snippet(snippet, max_width)` (search-result snippet rendering — see the `Content.assemble` gotcha), `_match_spans(text, matcher)` / `_highlight(text, spans)` / `_log_body(text, folds)` (log-viewer match highlighting, split so the regex half can run in a worker thread and only the `Content` half touches widgets), `_tool_collapsible(title, …)` (a `Collapsible` whose title is `Content`, never markup) - Instance helpers: `_build_session_label(session, state_suffix)` — the single source of truth for session row text (including the 🍴 fork marker), used by `_show_home`'s render of direct *and* worktree rows and by `_poll_session_states` for in-place updates; `_build_claude_session_items(sessions, prefix)` — shared row rendering for the resume (`rp-*`), fork (`fp-*`) and log-viewer (`lp-*`) pickers; `_matching_worktree(path)` — the project worktree a path refers to, compared by `resolve()`; `_direct_session_cwd(tmux_name)` — where a `direct-N` row really runs and on what branch (memoized in `_direct_cwd_cache`); `_resume_target(project, cwd, session=None)` — the tmux name and session type a resume should use -- Views: home (sessions list), session actions submenu, terminate/stop prompt (`#terminate-prompt`, opened by a pending `close` from `Ctrl-A x`; Terminate / Stop / Cancel with Terminate highlighted so Enter matches the `confirm-before` it replaces, and Cancel re-attaching via `_launch_target` so it costs nothing), finish flow, confirm dialog, create form, branch select (3 options), branch picker (filterable list), fork title form, fork branch select, fork session picker, session log picker + read-only log viewer, conflict resolution, project switcher (with autocomplete filter), tmux install, error +- Views: home (sessions list), session actions submenu, terminate/park/stop prompt (`#terminate-prompt`, opened by a pending `close` from `Ctrl-A x`; Terminate / Park / Stop / Cancel with Terminate highlighted so Enter matches the `confirm-before` it replaces, and Cancel re-attaching via `_launch_target` so it costs nothing), finish flow, confirm dialog, create form, branch select (3 options), branch picker (filterable list), fork title form, fork branch select, fork session picker, session log picker + read-only log viewer, conflict resolution, project switcher (with autocomplete filter), tmux install, error - Transcript search (`s`) — see the dedicated section below. - Home screen name filter: `/` arms a filter box (`#home-search`) mounted above `#home-list`. `action_search` reveals + focuses it; `Input.Changed` re-renders @@ -430,7 +442,7 @@ responsible for running it off the event loop. `_search_query`. Guarded on `_on_home`, so `r` is inert in other views. - Home screen sections: actions ("Restore N stopped sessions" when any exist, "New worktree session", "New session in X", "Ad hoc session"), sessions — running 🟢 *and* stopped 🟠 together, since the circle colour carries the distinction (with Claude state indicators on the running ones), inactive worktrees, 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 → Fork session → Resume previous session; for inactive worktrees, Resume previous session → Fork session → Launch (resume is the more common action when picking an idle worktree). Then: View session log (whenever the path has a previous Claude session), Open terminal, Open in VS Code, Rename, Stop session (active only), Terminate session (active or stopped), Finish (worktree only), Cancel. Stop and Terminate are two menu items rather than one item plus a prompt — a menu is already a choice — but both route into the single `_end_session(session, terminate=...)` handler, which is also what the `Ctrl-A x` prompt calls. Claude-session items show just "Resume" + View session log + Open terminal/VS Code + Cancel. "Fork session" is always inserted at index 1 (`items.insert(1, ...)`) so its position holds across both layouts. +- Session actions submenu (in order): for active sessions, Connect → Fork session → Resume previous session; for inactive worktrees, Resume previous session → Fork session → Launch (resume is the more common action when picking an idle worktree). Then: View session log (whenever the path has a previous Claude session), Open terminal, Open in VS Code, Rename, Park session and Stop session (active only), Terminate session (active or stopped), Finish (worktree only), Cancel. Stop and Terminate are two menu items rather than one item plus a prompt — a menu is already a choice — but both route into the single `_end_session(session, terminate=...)` handler, which is also what the `Ctrl-A x` prompt calls. Claude-session items show just "Resume" + View session log + Open terminal/VS Code + Cancel. "Fork session" is always inserted at index 1 (`items.insert(1, ...)`) so its position holds across both layouts. - "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. - Fork flow: `sa-fork` → `#fork-title-input` → `#fork-branch-list` (parent branch (default) / parent's base / another branch → the shared `_show_branch_picker`) → conversation picker `#fork-picker` (`fp-{i}`, only when >1 candidate) → the shared `_finalize_create` / `_do_create_and_launch`. Offered for worktree *and* direct sessions that have at least one previous Claude session. - Session log viewer: `sa-viewlog` → `_show_log_picker` → `_show_session_log`. The @@ -607,7 +619,7 @@ Three custom exception types, all caught in `main()`: `uv tool install --force --reinstall --no-cache .`, and verify with `fujimoto --version` (the `.devN` suffix moves) or by grepping the installed `site-packages/fujimoto/cli.py` for something the change added. -- **Remembering sessions across a restart**: `session_state.py` records every session as open at launch — in `main()`, **before** `launch_claude_in_tmux` blocks on the attach, so a host that dies mid-session still has a record to restore from. `_init_git_info` loads the pruned state into `_open_sessions`; `_stopped_records()` derives the open-but-not-running set for the current project; `_build_home_items` renders those in the *sessions* section as 🟠 and excludes them from *inactive worktrees*. A **Restore N stopped sessions** row relaunches them all via `create_session` (detached, resuming each path's latest transcript, attaching to none) — deliberately without `_apply_worktree_config`, which would run N `init` blocks up front; config still runs when the user actually attaches. `_end_session(session, terminate=...)` is the single handler behind both menu items and both outcomes of the `Ctrl-A x` prompt; it tolerates a `kill_session` failure only when `session_exists` confirms the session is already gone (otherwise marking a live session closed would hide it). `_do_delete_worktree` and `on_rename_submitted` keep the store honest via `mark_closed` / `rename`. +- **Remembering sessions across a restart**: `session_state.py` records every session as open at launch — in `main()`, **before** `launch_claude_in_tmux` blocks on the attach, so a host that dies mid-session still has a record to restore from. `_init_git_info` loads the pruned state into `_open_sessions`; `_stopped_records()` derives the open-but-not-running set for the current project; `_build_home_items` renders those in the *sessions* section as 🟠 (🅿️ when `parked`, sorted after the running rows and before the stopped ones) and excludes them from *inactive worktrees*. A **Restore N stopped sessions** row relaunches the unparked ones via `create_session` (detached, resuming each path's latest transcript, attaching to none) — deliberately without `_apply_worktree_config`, which would run N `init` blocks up front; config still runs when the user actually attaches. `_end_session(session, terminate=..., park=...)` is the single handler behind every menu item and every outcome of the `Ctrl-A x` prompt; it tolerates a `kill_session` failure only when `session_exists` confirms the session is already gone (otherwise marking a live session closed would hide it). `_do_delete_worktree` and `on_rename_submitted` keep the store honest via `mark_closed` / `rename`. - **Session metadata**: `.fujimoto/meta.json` stored in worktree directory records the base branch for cherry-pick targeting, the `source_root` (main repo) for project-config source resolution, and — for forks — `forked_from_session_id` plus `forked_from_worktree`. The `.fujimoto/` directory contains a `.gitignore` with `*` so its contents are automatically ignored by git. - **Project config (`.fujimoto.yaml`)**: An optional, committed per-project file (`project_config.py`) declaring files to copy/link into a worktree and init commands to run. Applied centrally in `main()`'s launch loop (parent process, **before** `tmux attach`) by `_apply_worktree_config(working_dir)`, for **every** worktree connection mode (new, reconnect-to-live, relaunch/resume) — so copy/link/init run on each connect, not just creation. `_do_create_and_launch` no longer applies config; it only creates the worktree and stores meta. Key mechanics: - **Config is read from the source root (main clone), not the worktree.** `.fujimoto.yaml` is a local, uncommitted file in the main clone, so it isn't present in a worktree checkout — `_apply_worktree_config` calls `load_project_config(source_root)`. (`--create-config` writes it to the main clone's root.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 865bdaf..87c8edc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -319,13 +319,14 @@ https://docs.pypi.org/trusted-publishers/ cannot be used to test key bindings — it injects into the pane and bypasses key-table dispatch; to automate it, write the keys to an attached client's pty instead (`pty.fork()`, then `os.write(fd, b"\x01f")`). -8. Test stop / terminate and restart recovery: +8. Test stop / park / terminate and restart recovery: - Attach to a session and press `Ctrl-A s` — it should detach with no prompt, and reappear on the home screen as 🟠 in the *sessions* section - Select it → **Launch** or **Resume previous session** — it goes back to 🟢 - Press `Ctrl-A x` with claude alone in the window — the TUI should reappear - on the terminate prompt; Enter terminates (⚫), `Stop` keeps it 🟠, and - `Cancel` drops you straight back into the session having killed nothing + on the terminate prompt; Enter terminates (⚫), `Stop` keeps it 🟠, + `Park` keeps it 🅿️, and `Cancel` drops you straight back into the session + having killed nothing - Press `Ctrl-A t` to split, then `Ctrl-A x` — it must fall back to the original `kill pane? (y/n)` confirm and leave the session alone - Simulate a restart with `tmux kill-server` while sessions are open, then @@ -333,6 +334,9 @@ https://docs.pypi.org/trusted-publishers/ **Restore N stopped sessions** row that relaunches them all detached - Terminate one through the menu, then `tmux kill-server` again — the terminated one must stay ⚫ while the others return 🟠 + - Park a session (menu item or the `Ctrl-A x` prompt): it renders 🅿️ above + the 🟠 rows, the **Restore** row's count drops by one and leaves it alone, + and launching it clears the flag back to 🟢 - Check `~/.cache/fujimoto/sessions.json` holds a record per open session and nothing for terminated ones As with `Ctrl-A f`, `tmux send-keys` cannot drive these bindings; write to an diff --git a/README.md b/README.md index 41468db..a3a075d 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ via `FUJIMOTO_META_KEY`) to enter a one-shot "fujimoto mode", then: | `f` | Fork this session — detaches back to the fujimoto TUI and opens the **Fork session** flow for this worktree, so you get the usual name prompt, base-branch menu and conversation picker. | | `s` | Stop this session — closes claude but keeps the session in your list as 🟠, ready to resume. No prompt. | | `d` | Detach the tmux session (returns you to the fujimoto TUI). | -| `x` | End this session. With a split open it kills just that pane (with confirmation), as before. With claude alone in the window it detaches to the fujimoto TUI and asks whether to **terminate** (default) or merely **stop** it. | +| `x` | End this session. With a split open it kills just that pane (with confirmation), as before. With claude alone in the window it detaches to the fujimoto TUI and asks whether to **terminate** (default), **park** 🅿️ or merely **stop** 🟠 it. | | `[` | Enter copy mode (scrollback / selection). | | `?` | Flash the binding cheatsheet in the status bar. | @@ -269,6 +269,7 @@ fujimoto ───── sessions ───── 🟢 20260309-cleanup-ui (worktree) 🟢 direct-1 (direct @ main) +🅿️ 20260305-spike-caching (worktree) 🟠 20260307-parser-rewrite (worktree) 🟠 direct-2 (direct @ main) ───── inactive worktrees ───── @@ -289,6 +290,7 @@ what you had open instead: | | Meaning | |---|---| | 🟢 | Running right now. | +| 🅿️ | **Parked** — stopped on purpose and set aside. Resume it whenever you like; **Restore** leaves it where it is. | | 🟠 | **Stopped** — not running, but you never told fujimoto you were done with it. Resume it and carry on. | | ⚫ | An inactive worktree: terminated through fujimoto, or never launched from it. | @@ -298,20 +300,28 @@ host restart, `tmux kill-session` from another terminal, closing the window, `exit` in the pane — leaves it stopped and resumable. There is no guessing about why a session went away. +Sessions are listed running first, then parked, then stopped, so the ones +still in flight stay at the top. + **Restore** appears at the top of the home screen whenever something is stopped. It relaunches every stopped session in the project at once, each resuming its most recent conversation, and attaches to none of them — pick the -one you want to sit in from the list. +one you want to sit in from the list. Parked sessions are skipped: you put them +down deliberately, unlike the ones a restart took away. **Stop** ends the claude process but keeps the session history: the transcript is untouched and the conversation resumes where it left off. Any task claude was part-way through is interrupted, so stop at a natural break. +**Park** does exactly what Stop does, but flags the session so it shows as 🅿️ +and sits out the bulk restore — for work you are consciously shelving rather +than pausing. Launching or resuming a parked session un-parks it. + #### Filtering sessions by name — `/` Press `/` on the home screen to open a filter box, then type. Matching is live and case-insensitive, against session names and branch names, and covers the -**sessions** (running and stopped), **inactive worktrees** and **previous claude +**sessions** (running, parked and stopped), **inactive worktrees** and **previous claude sessions** lists at once. While a filter is active the action rows (`+ New …`, settings, switch project) are hidden so only matches remain. @@ -396,14 +406,14 @@ Select any session to see contextual options: | Session State | Options | |--------------|---------| -| Active worktree | Connect, Fork session, Resume previous session, View session log, Stop, Terminate, Finish | +| Active worktree | Connect, Fork session, Resume previous session, View session log, Park, Stop, Terminate, Finish | | Stopped worktree | Resume previous session, Fork session, View session log, Launch, Terminate, Finish | | Inactive worktree | Resume previous session, Fork session, View session log, Launch, Finish | -| Active direct | Connect, Fork session, Resume previous session, View session log, Stop, Terminate | +| Active direct | Connect, Fork session, Resume previous session, View session log, Park, Stop, Terminate | -**Stop** keeps the session in your list as 🟠; **Terminate** marks it done and -drops it to ⚫. Both close claude — the difference is only whether fujimoto -offers it back to you. +**Stop** keeps the session in your list as 🟠 and **Park** keeps it as 🅿️; +**Terminate** marks it done and drops it to ⚫. All three close claude — the +difference is only whether fujimoto offers it back to you, and how eagerly. All session types also offer **Open terminal**, **Open in VS Code** and **Rename**. diff --git a/src/fujimoto/cli.py b/src/fujimoto/cli.py index 71624a4..11589ab 100644 --- a/src/fujimoto/cli.py +++ b/src/fujimoto/cli.py @@ -129,6 +129,7 @@ ICON_ZZZ = "\U0001f4a4" ICON_GREEN_CIRCLE = "\U0001f7e2" ICON_ORANGE_CIRCLE = "\U0001f7e0" +ICON_PARKED = "\U0001f17f\ufe0f" ICON_BLACK_CIRCLE = "\u26ab" ICON_HLINE = "\u2500" ICON_WIZARD = "\U0001f9d9\U0001f3fd\u200d\u2642\ufe0f" @@ -1112,6 +1113,9 @@ class SessionInfo: # Not running, but the user never terminated it through fujimoto — so it # is still theirs to come back to. Renders orange, resumes by default. is_stopped: bool = field(default=False) + # Stopped on purpose and set aside. Renders blue and is skipped by the bulk + # restore; in every other respect it behaves exactly like a stopped session. + is_parked: bool = field(default=False) claude_session_id: str | None = field(default=None) claude_state: SessionState | None = field(default=None) is_fork: bool = field(default=False) @@ -1563,7 +1567,9 @@ async def _show_home(self) -> None: def _stopped_records(self) -> dict[str, session_state.SessionRecord]: """Open records for this project with no live tmux session behind them. - These are the sessions a restart (or any out-of-band kill) took away. + These are the sessions a restart (or any out-of-band kill) took away, + plus the ones the user parked. Both render here; only the unparked ones + are swept up by "Restore stopped sessions". Ad hoc sessions are excluded because they are not project-scoped and so never appear on a project's home screen. """ @@ -1732,11 +1738,12 @@ def _build_home_items(self) -> list[ListItem]: claimed_claude_ids: set[str] = set() stopped_records = self._stopped_records() + restorable = {n: r for n, r in stopped_records.items() if not r.parked} items: list[ListItem] = [] if not searching: - if stopped_records: - count = len(stopped_records) + if restorable: + count = len(restorable) plural = "s" if count != 1 else "" items.append( ListItem( @@ -1850,8 +1857,11 @@ def _build_home_items(self) -> list[ListItem]: label_text = self._build_session_label(info, state_suffix) active_items.append(ListItem(Label(label_text, markup=True), id=item_id)) - # Stopped sessions sit in the same section as running ones: the circle - # colour carries the distinction, so there is no need to split them out. + # Stopped and parked sessions sit in the same section as running ones: + # the icon carries the distinction. They are collected separately only + # so the section reads running, then parked, then stopped. + parked_items: list[ListItem] = [] + stopped_items: list[ListItem] = [] for sname, rec in sorted(stopped_records.items()): display_name = sname.split("/", 1)[1] if "/" in sname else sname is_worktree = rec.session_type == "worktree" @@ -1877,6 +1887,7 @@ def _build_home_items(self) -> list[ListItem]: tmux_session=sname, is_active=False, is_stopped=True, + is_parked=rec.parked, branch=branch, claude_session_id=cs_id, claude_state=cs.state if cs else None, @@ -1884,7 +1895,10 @@ def _build_home_items(self) -> list[ListItem]: ) self._session_map[item_id] = info label_text = self._build_session_label(info, "") - active_items.append(ListItem(Label(label_text, markup=True), id=item_id)) + bucket = parked_items if rec.parked else stopped_items + bucket.append(ListItem(Label(label_text, markup=True), id=item_id)) + + active_items += parked_items + stopped_items if active_items: items.append( @@ -2631,18 +2645,26 @@ def _build_session_label(self, session: SessionInfo, state_suffix: str) -> str: f"{ICON_GREEN_CIRCLE} {session.name}{fork}" f" [dim]({BRANCH_ICON} {session.branch})[/]{state_suffix}" ) - icon = ICON_ORANGE_CIRCLE if session.is_stopped else ICON_BLACK_CIRCLE + if session.is_parked: + icon = ICON_PARKED + elif session.is_stopped: + icon = ICON_ORANGE_CIRCLE + else: + icon = ICON_BLACK_CIRCLE return f"{icon} {session.name}{fork} [dim]({BRANCH_ICON} {session.branch})[/]" # -- Stopping and terminating -- - async def _end_session(self, session: SessionInfo, *, terminate: bool) -> None: + async def _end_session( + self, session: SessionInfo, *, terminate: bool, park: bool = False + ) -> None: """Kill a session's tmux session, and set its intent. - The single handler behind both menu items and both outcomes of the + The single handler behind every menu item and every outcome of the `Ctrl-A x` prompt. Stopping leaves the record open so the session comes - back orange; terminating forgets it, which is the only way a session - stops being open. + back orange; parking does the same but flags the record, so it comes + back blue and the bulk restore skips it; terminating forgets it, which + is the only way a session stops being open. """ try: if session.is_active: @@ -2659,7 +2681,13 @@ async def _end_session(self, session: SessionInfo, *, terminate: bool) -> None: if terminate: session_state.mark_closed(session.tmux_session) else: - session_state.touch(session.tmux_session, session.claude_session_id) + # `park=False` on a plain stop would un-park an already-parked + # session, so only a park ever writes the flag. + session_state.touch( + session.tmux_session, + session.claude_session_id, + parked=True if park else None, + ) self._init_git_info() await self._show_home() except (TmuxError, ConfigError, GitError) as e: @@ -2674,9 +2702,9 @@ async def _open_pending_close(self) -> None: await self._show_terminate_prompt(target) async def _show_terminate_prompt(self, target: LaunchTarget) -> None: - """Terminate / stop / cancel, defaulting to terminate. + """Terminate / park / stop / cancel, defaulting to terminate. - Three options, not two: the `confirm-before` this replaces could be + More than two options: the `confirm-before` this replaces could be answered `n`, and losing that would be a regression. Cancel re-attaches immediately rather than dropping the user on the home screen, so it is a true no-op. @@ -2698,6 +2726,14 @@ async def _show_terminate_prompt(self, target: LaunchTarget) -> None: ), id="tp-terminate", ), + ListItem( + Label( + f"Park [dim]— set it aside " + f"{ICON_PARKED}, history preserved[/]", + markup=True, + ), + id="tp-park", + ), ListItem( Label( f"Stop [dim]— keep it for later " @@ -2745,17 +2781,24 @@ async def on_terminate_prompt_selected(self, event: ListView.Selected) -> None: branch="", ) self._pending_close_target = None - await self._end_session(session, terminate=event.item.id == "tp-terminate") + await self._end_session( + session, + terminate=event.item.id == "tp-terminate", + park=event.item.id == "tp-park", + ) async def _restore_stopped_sessions(self) -> None: """Relaunch every stopped session in this project, attaching to none. Each comes back resuming its most recent conversation, so a forced - restart costs the user one keypress. Project config is deliberately not - applied here — it runs when the user actually attaches to one. + restart costs the user one keypress. Parked sessions are left alone — + they were set aside deliberately, unlike the ones a restart took away. + Project config is deliberately not applied here — it runs when the user + actually attaches to one. """ failures: list[str] = [] - for name, rec in sorted(self._stopped_records().items()): + records = {n: r for n, r in self._stopped_records().items() if not r.parked} + for name, rec in sorted(records.items()): sessions = get_sessions_for_path(rec.path) resume_id = sessions[0].session_id if sessions else rec.claude_session_id try: @@ -2840,6 +2883,7 @@ async def _show_session_actions( # already a choice. Both land in one handler, as the tmux prompt does. if session.session_type != "claude": if session.is_active: + items.append(ListItem(Label("Park session"), id="sa-park")) items.append(ListItem(Label("Stop session"), id="sa-stop")) if session.is_active or session.is_stopped: items.append(ListItem(Label("Terminate session"), id="sa-terminate")) @@ -2864,6 +2908,8 @@ async def _show_session_actions( ) if session.is_active: status_label = "active" + elif session.is_parked: + status_label = "parked" elif session.is_stopped: status_label = "stopped" else: @@ -4168,8 +4214,12 @@ async def on_session_action_selected(self, event: ListView.Selected) -> None: await self._show_fork_title_form(session) elif action == "sa-viewlog": await self._show_log_picker(session) - elif action in ("sa-stop", "sa-terminate"): - await self._end_session(session, terminate=action == "sa-terminate") + elif action in ("sa-park", "sa-stop", "sa-terminate"): + await self._end_session( + session, + terminate=action == "sa-terminate", + park=action == "sa-park", + ) elif action == "sa-terminal": await self._show_terminal_mode(session) elif action == "sa-vscode": diff --git a/src/fujimoto/session_state.py b/src/fujimoto/session_state.py index 6ad87ab..b55660d 100644 --- a/src/fujimoto/session_state.py +++ b/src/fujimoto/session_state.py @@ -6,6 +6,11 @@ an ``exit`` in the pane, a tmux crash, a host restart — keeps its record and is shown as *stopped*, ready to resume. +*Parking* is a user-chosen flavour of stopped: the record stays open exactly as +a stop leaves it, but it is flagged so the home screen draws it blue and the +bulk restore leaves it alone — a session set aside on purpose should not come +back with the ones a crash took away. + That single rule is the whole design: there is no boot-time detection and no reconciliation pass. A record's presence means "open"; its absence means "closed", which is also what a session fujimoto has never launched looks like. @@ -44,6 +49,10 @@ class SessionRecord: session_type: str = "" branch: str = "" claude_session_id: str | None = None + # Parked is a *flavour* of stopped, not a third intent: the record is open + # either way, and the flag only changes which icon the home screen draws + # and whether "Restore stopped sessions" sweeps it up. + parked: bool = False last_seen: str = "" @property @@ -87,7 +96,14 @@ def load_state() -> dict[str, SessionRecord]: records: dict[str, SessionRecord] = {} skipped = 0 - fields = {"cwd", "project", "session_type", "branch", "claude_session_id"} + fields = { + "cwd", + "project", + "session_type", + "branch", + "claude_session_id", + "parked", + } for name, raw in data.items(): if not isinstance(raw, dict) or not isinstance(raw.get("cwd"), str): # A malformed record is silently dropped, which otherwise looks @@ -96,6 +112,9 @@ def load_state() -> dict[str, SessionRecord]: debug.log("session_state.skipped", session=debug.rv(name)) continue kwargs = {k: v for k, v in raw.items() if k in fields} + # A record written by a fujimoto that predates parking has no flag, and + # a hand-edited one may have the wrong type; either way it is not parked. + kwargs["parked"] = bool(kwargs.get("parked")) records[name] = SessionRecord( **kwargs, last_seen=raw.get("last_seen") or "", @@ -135,7 +154,11 @@ def mark_open( branch: str = "", claude_session_id: str | None = None, ) -> None: - """Record that a session is open. Called on every launch and reconnect.""" + """Record that a session is open. Called on every launch and reconnect. + + Launching clears the parked flag: a session you are sitting in is not + parked, whatever it was before. + """ state = load_state() existing = state.get(tmux_name) # A reconnect knows nothing new about the conversation, so don't let it @@ -172,8 +195,17 @@ def mark_closed(tmux_name: str) -> None: save_state(state) -def touch(tmux_name: str, claude_session_id: str | None = None) -> None: - """Refresh a record without changing its intent (used when stopping).""" +def touch( + tmux_name: str, + claude_session_id: str | None = None, + *, + parked: bool | None = None, +) -> None: + """Refresh a record without changing its intent (used when stopping). + + `parked` is tri-state: `None` leaves the flag alone, so an ordinary stop of + an already-parked session does not quietly un-park it. + """ state = load_state() record = state.get(tmux_name) if record is None: @@ -184,10 +216,13 @@ def touch(tmux_name: str, claude_session_id: str | None = None) -> None: session=debug.rv(tmux_name), found=True, claude_session=claude_session_id or "unchanged", + parked="unchanged" if parked is None else parked, ) record.last_seen = _now() if claude_session_id is not None: record.claude_session_id = claude_session_id + if parked is not None: + record.parked = parked save_state(state) diff --git a/tests/test_cli.py b/tests/test_cli.py index 908ebba..6969bc5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,6 +28,7 @@ ICON_FORK, ICON_GEAR, ICON_ORANGE_CIRCLE, + ICON_PARKED, ICON_SHIELD, LaunchTarget, SessionApp, @@ -4965,6 +4966,7 @@ def _record( project: str = "test-proj", session_type: str = "worktree", claude_session_id: str | None = None, + parked: bool = False, ): """Build an open-session record whose directory actually exists.""" from fujimoto.session_state import SessionRecord @@ -4977,6 +4979,7 @@ def _record( session_type=session_type, branch=f"worktree/{name}" if session_type == "worktree" else "feat/test", claude_session_id=claude_session_id, + parked=parked, ) @@ -5080,6 +5083,180 @@ async def test_search_filters_stopped_rows(self, tmp_path: Path) -> None: assert "wt-a" not in _list_text(app) +class TestParkedSessions: + @pytest.mark.asyncio + async def test_renders_blue_p(self, tmp_path: Path) -> None: + records = {"test-proj/wt-a": _record(tmp_path, parked=True)} + with _patch_git_info(open_sessions=records): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + info = app._session_map["wt-wt-a"] + assert info.is_parked is True + # Parked is a flavour of stopped, so everything that keys off + # is_stopped (resume, terminate, the sessions section) still works. + assert info.is_stopped is True + assert app._build_session_label(info, "").startswith(ICON_PARKED) + + @pytest.mark.asyncio + async def test_sorts_between_running_and_stopped(self, tmp_path: Path) -> None: + records = { + "test-proj/wt-live": _record(tmp_path, name="wt-live"), + "test-proj/wt-park": _record(tmp_path, name="wt-park", parked=True), + "test-proj/wt-stop": _record(tmp_path, name="wt-stop"), + } + with _patch_git_info( + sessions=["test-proj/wt-live"], + worktrees=[Path("wt-live")], + open_sessions=records, + ): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + rows = [ + i.id + for i in app.query("#home-list").first(ListView).children + if i.id and i.id.startswith("wt-") + ] + assert rows == ["wt-wt-live", "wt-wt-park", "wt-wt-stop"] + + @pytest.mark.asyncio + async def test_restore_row_ignores_parked(self, tmp_path: Path) -> None: + records = { + "test-proj/wt-a": _record(tmp_path, name="wt-a"), + "test-proj/wt-b": _record(tmp_path, name="wt-b", parked=True), + } + with _patch_git_info(open_sessions=records): + with patch("fujimoto.cli.create_session") as create: + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + assert "Restore 1 stopped session" in _list_text(app) + await app._restore_stopped_sessions() + create.assert_called_once() + assert create.call_args.args[0] == "test-proj/wt-a" + + @pytest.mark.asyncio + async def test_all_parked_hides_the_restore_row(self, tmp_path: Path) -> None: + records = {"test-proj/wt-a": _record(tmp_path, parked=True)} + with _patch_git_info(open_sessions=records): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + assert "Restore" not in _list_text(app) + + @pytest.mark.asyncio + async def test_park_flags_the_record_and_launch_clears_it( + self, tmp_path: Path + ) -> None: + from fujimoto import session_state + + session = SessionInfo( + name="wt-a", + session_type="worktree", + project="test-proj", + path=tmp_path / "wt-a", + tmux_session="test-proj/wt-a", + is_active=True, + branch="worktree/wt-a", + ) + with _patch_git_info(): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + session_state.mark_open( + "test-proj/wt-a", + cwd=tmp_path, + project="test-proj", + session_type="worktree", + ) + with patch("fujimoto.cli.kill_session") as kill: + await app._end_session(session, terminate=False, park=True) + kill.assert_called_once_with("test-proj/wt-a") + assert session_state.load_state()["test-proj/wt-a"].parked is True + + # Relaunching is how a session leaves the parked state. + session_state.mark_open( + "test-proj/wt-a", + cwd=tmp_path, + project="test-proj", + session_type="worktree", + ) + assert session_state.load_state()["test-proj/wt-a"].parked is False + + @pytest.mark.asyncio + async def test_stopping_a_parked_session_leaves_the_flag( + self, tmp_path: Path + ) -> None: + from fujimoto import session_state + + session = SessionInfo( + name="wt-a", + session_type="worktree", + project="test-proj", + path=tmp_path / "wt-a", + tmux_session="test-proj/wt-a", + is_active=True, + branch="worktree/wt-a", + ) + with _patch_git_info(): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + session_state.mark_open( + "test-proj/wt-a", + cwd=tmp_path, + project="test-proj", + session_type="worktree", + ) + session_state.touch("test-proj/wt-a", parked=True) + with patch("fujimoto.cli.kill_session"): + await app._end_session(session, terminate=False) + assert session_state.load_state()["test-proj/wt-a"].parked is True + + @pytest.mark.asyncio + async def test_actions_menu_offers_park_and_says_parked( + self, tmp_path: Path + ) -> None: + with _patch_git_info(): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.pause() + active = SessionInfo( + name="wt-a", + session_type="worktree", + project="test-proj", + path=tmp_path / "wt-a", + tmux_session="test-proj/wt-a", + is_active=True, + branch="worktree/wt-a", + ) + await app._show_session_actions(active) + await pilot.pause() + ids = [ + i.id for i in app.query("#session-actions").first(ListView).children + ] + assert "sa-park" in ids + + parked = SessionInfo( + name="wt-a", + session_type="worktree", + project="test-proj", + path=tmp_path / "wt-a", + tmux_session="test-proj/wt-a", + is_active=False, + is_stopped=True, + is_parked=True, + branch="worktree/wt-a", + ) + await app._show_session_actions(parked) + await pilot.pause() + info = " ".join( + str(w.render()) for w in app.query("#main").first().query(Static) + ) + assert "parked" in info + + class TestRestoreStoppedSessions: @pytest.mark.asyncio async def test_row_shows_count_and_pluralises(self, tmp_path: Path) -> None: @@ -5350,13 +5527,15 @@ async def test_menu_items_route_to_one_handler(self, tmp_path: Path) -> None: await pilot.pause() app._selected_session = self._session(tmp_path) with patch.object(app, "_end_session") as end: - for item_id, terminate in ( - ("sa-stop", False), - ("sa-terminate", True), + for item_id, terminate, park in ( + ("sa-stop", False, False), + ("sa-park", False, True), + ("sa-terminate", True, False), ): event = SimpleNamespace(item=SimpleNamespace(id=item_id)) await app.on_session_action_selected(event) # type: ignore[arg-type] assert end.call_args.kwargs["terminate"] is terminate + assert end.call_args.kwargs["park"] is park class TestTerminatePrompt: @@ -5373,7 +5552,7 @@ async def test_opens_on_mount_with_terminate_default(self, tmp_path: Path) -> No await pilot.pause() prompt = app.query("#terminate-prompt").first(ListView) ids = [i.id for i in prompt.children] - assert ids == ["tp-terminate", "tp-stop", "tp-cancel"] + assert ids == ["tp-terminate", "tp-park", "tp-stop", "tp-cancel"] # Enter therefore terminates, matching the confirm it replaces. assert prompt.index == 0 @@ -5429,6 +5608,27 @@ async def test_stop_keeps_record(self, tmp_path: Path) -> None: kill.assert_called_once_with("test-proj/wt-a") assert "test-proj/wt-a" in session_state.load_state() + @pytest.mark.asyncio + async def test_park_flags_the_record(self, tmp_path: Path) -> None: + from fujimoto import session_state + + (tmp_path / "wt-a").mkdir(exist_ok=True) + with _patch_git_info(): + app = SessionApp(pending_close=self._target(tmp_path)) + async with app.run_test() as pilot: + await pilot.pause() + session_state.mark_open( + "test-proj/wt-a", + cwd=tmp_path / "wt-a", + project="test-proj", + session_type="worktree", + ) + with patch("fujimoto.cli.kill_session") as kill: + event = SimpleNamespace(item=SimpleNamespace(id="tp-park")) + await app.on_terminate_prompt_selected(event) # type: ignore[arg-type] + kill.assert_called_once_with("test-proj/wt-a") + assert session_state.load_state()["test-proj/wt-a"].parked is True + @pytest.mark.asyncio async def test_cancel_reattaches(self, tmp_path: Path) -> None: target = self._target(tmp_path)