diff --git a/collins/prefs.py b/collins/prefs.py index b37208b..d0ef542 100644 --- a/collins/prefs.py +++ b/collins/prefs.py @@ -249,6 +249,16 @@ def __init__(self, state: AppState, on_change: Callable[[], None]) -> None: self._auto_title_row.set_active(bool(state.get_setting("auto_title_sessions"))) self._auto_title_row.connect("notify::active", self._on_auto_title_changed) sidebar_group.add(self._auto_title_row) + self._pr_title_row = Adw.SwitchRow( + title=_("Rename sessions after their pull requests"), + subtitle=_( + "Retitle a session to match the newest pull request opened " + "in it; manually renamed sessions keep their name" + ), + ) + self._pr_title_row.set_active(bool(state.get_setting("pr_title_sessions"))) + self._pr_title_row.connect("notify::active", self._on_pr_title_changed) + sidebar_group.add(self._pr_title_row) page.add(sidebar_group) self._footer_apps_group = Adw.PreferencesGroup( @@ -559,6 +569,10 @@ def _on_auto_title_changed(self, row: Adw.SwitchRow, _pspec) -> None: self._state.set_setting("auto_title_sessions", row.get_active()) self._on_change() + def _on_pr_title_changed(self, row: Adw.SwitchRow, _pspec) -> None: + self._state.set_setting("pr_title_sessions", row.get_active()) + self._on_change() + def _on_worktree_changed(self, row: Adw.SwitchRow, _pspec) -> None: self._state.set_setting("worktree_new_sessions", row.get_active()) self._on_change() diff --git a/collins/prstatus.py b/collins/prstatus.py index ae8fee1..33ebea4 100644 --- a/collins/prstatus.py +++ b/collins/prstatus.py @@ -425,6 +425,19 @@ def from_records(records: object) -> list[PullRequest]: return [pr for record in records if (pr := from_record(record)) is not None] +def newest_title(records: object) -> str | None: + """The newest saved PR's title, or None while no saved PR has one. + + What the pr_title_sessions setting renames a session to (see + SessionStore.apply_pr_title). A saved list is oldest-first, so the last + titled entry is the PR the session opened most recently; a PR whose title + hasn't arrived from `gh` yet (a bare pr-link, say) contributes nothing + until a refresh lands one. + """ + titles = [pr.title for pr in from_records(records) if pr.title] + return titles[-1] if titles else None + + def _load_cache() -> dict: """The whole gh PR status cache, or {} when it is missing or unusable.""" try: diff --git a/collins/sidebar.py b/collins/sidebar.py index 36e4310..f649af1 100644 --- a/collins/sidebar.py +++ b/collins/sidebar.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-02. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. """Session sidebar: search, project accordion, favorites, selection mode. @@ -613,6 +613,7 @@ def _pr_menu_refreshed(self, token: int, prs: list[PullRequest]) -> bool: return GLib.SOURCE_REMOVE self._prs = prs self._sidebar.store.state.set_session_prs(self.item.session_id, to_records(prs)) + self._sidebar.store.apply_pr_title(self.item.session_id) if self._pr_menu.get_visible(): prmenu.update(self._pr_menu, prs, self._pr_host) return GLib.SOURCE_REMOVE diff --git a/collins/state.py b/collins/state.py index 9e019e6..833ab77 100644 --- a/collins/state.py +++ b/collins/state.py @@ -60,6 +60,10 @@ def _migrate_old_config() -> None: # the env half only takes effect for tabs opened after a change. "progress_termprop": True, "auto_title_sessions": True, # summarize each new session's first prompt into a short title + # Retitle a session to its newest pull request's title as PRs are + # detected (see SessionStore.apply_pr_title). Fills the generated-name + # slot, so a manual rename always wins. + "pr_title_sessions": False, # Launch new sessions with the agent CLI's worktree flag (claude -w) in # git projects, isolating their edits from the live checkout. Per-project # overrides live in AppState.project_worktree. diff --git a/collins/store.py b/collins/store.py index e9f4250..992c589 100644 --- a/collins/store.py +++ b/collins/store.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-02. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. """SessionStore: the single source of truth between disk and UI. @@ -24,6 +24,7 @@ from .i18n import _ from .models import CHATS_GROUP, FAV_GROUP, SessionItem from .providers import available_providers +from .prstatus import newest_title from .sessions import ( Session, discover_sessions, @@ -109,6 +110,9 @@ def __init__(self, state: AppState) -> None: self._last_sessions: list[Session] = [] self._first_scan = True self._regen_pending: set[str] = set() # ids whose regen should replace a manual name + # Last-seen pr_title_sessions value, so apply_pr_titles sweeps only + # when the setting flips on rather than on every preferences apply. + self._pr_titles_on = bool(state.get_setting("pr_title_sessions")) self._monitors: list[Gio.FileMonitor] = [] self._refresh_queued = False self._scanning = False @@ -441,6 +445,45 @@ def rename(self, session_id: str, name: str) -> None: self.state.set_name(session_id, name) self._apply() + def apply_pr_title(self, session_id: str) -> None: + """Retitle *session_id* after the newest PR it has opened. + + The whole pr_title_sessions setting: called wherever a session's + saved PR list is (re)written — a tab's poll, the sidebar's PR menu — + and a no-op until one of those lands a titled record. Writes the + generated-name slot, so a manual rename still wins in display_name + and the auto-title paths already know to skip the session. + """ + if not self.state.get_setting("pr_title_sessions"): + return + title = newest_title(self.state.get_session_prs(session_id)) + if not title or title == self.state.get_generated_name(session_id): + return + self.state.set_generated_name(session_id, title) + self._apply() + + def apply_pr_titles(self) -> None: + """The catch-up half of pr_title_sessions: every known session at + once, for when the setting is switched on with PRs already saved. + + Called on every preferences apply — that's the only signal there is — + so the off→on flip is detected here: a save that didn't just flip the + setting returns without walking a thing. While the setting stays on, + apply_pr_title at each detection site is what keeps names current. + """ + on = bool(self.state.get_setting("pr_title_sessions")) + was_on, self._pr_titles_on = self._pr_titles_on, on + if not on or was_on: + return + names: dict[str, str] = {} + for session_id in self.sessions: + title = newest_title(self.state.get_session_prs(session_id)) + if title and title != self.state.get_generated_name(session_id): + names[session_id] = title + if names: + self.state.set_generated_names(names) + self._apply() + def toggle_favorite(self, session_id: str) -> None: self.state.toggle_favorite(session_id) self._apply() diff --git a/collins/window.py b/collins/window.py index b672360..e04bde6 100644 --- a/collins/window.py +++ b/collins/window.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-02. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. """Main window: composes the session sidebar with the tabbed terminal area.""" from __future__ import annotations @@ -1588,6 +1588,7 @@ def _on_tab_prs_changed(self, tab: TerminalTab, records: object) -> None: return self.state.set_session_prs(tab.session_id, list(records or [])) self.sidebar.sync_session_prs(tab.session_id) + self.store.apply_pr_title(tab.session_id) def _on_panel_size_changed(self, _tab: TerminalTab, mode: str, size: int) -> None: """A divider was dragged: remember the size app-wide, so every panel @@ -3609,6 +3610,7 @@ def _apply_preferences(self) -> None: self.sidebar.refresh_usage_panel() self.sidebar.refresh_project_icon_size() self._bg_status.set_polling(bool(self.state.get_setting("background_status_poll"))) + self.store.apply_pr_titles() def _apply_settings_to_tabs(self) -> None: for i in range(self.tab_view.get_n_pages()): diff --git a/po/collins.pot b/po/collins.pot index 866d942..d8966c0 100644 --- a/po/collins.pot +++ b/po/collins.pot @@ -443,6 +443,16 @@ msgid "" "prompt" msgstr "" +#: collins/prefs.py:253 +msgid "Rename sessions after their pull requests" +msgstr "" + +#: collins/prefs.py:255 +msgid "" +"Retitle a session to match the newest pull request opened in it; manually " +"renamed sessions keep their name" +msgstr "" + #: collins/prefs.py:190 collins/prefs.py:196 msgid "Language" msgstr "" diff --git a/tests/test_prstatus.py b/tests/test_prstatus.py index c4d721a..3c4cf7b 100644 --- a/tests/test_prstatus.py +++ b/tests/test_prstatus.py @@ -25,6 +25,7 @@ invalidate, menu_name, merge_ordered, + newest_title, parse_pr_link, refresh, resync, @@ -830,6 +831,31 @@ def test_from_records_tolerates_junk_in_place_of_a_list(): assert from_records({"number": 55, "url": URL}) == [] +def test_newest_title_is_the_last_titled_records(): + """Two titled PRs: the later one is what the session gets renamed to.""" + records = [ + {"number": 40, "url": "https://github.com/episode6/collins/pull/40", "title": "Old work"}, + {"number": 55, "url": URL, "title": "New work"}, + ] + assert newest_title(records) == "New work" + + +def test_newest_title_skips_untitled_records(): + """A fresh pr-link has no title until gh answers; it must not blank the + name a titled predecessor already provided.""" + records = [ + {"number": 40, "url": "https://github.com/episode6/collins/pull/40", "title": "Old work"}, + {"number": 55, "url": URL}, + ] + assert newest_title(records) == "Old work" + + +def test_newest_title_none_without_any_title(): + assert newest_title([{"number": 55, "url": URL}]) is None + assert newest_title([]) is None + assert newest_title("junk") is None + + # -- merge_ordered (saved list + transcript links) -------------------------- diff --git a/tests/test_state.py b/tests/test_state.py index adfe885..07109c2 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-02. Full change history: git log for this file. +# fork. Last modified: 2026-08-03. Full change history: git log for this file. import json @@ -90,6 +90,13 @@ def test_defaults_for_unknown_settings(app_state): assert state.get_setting("font") == "" +def test_pr_title_sessions_setting(app_state): + state = app_state.AppState() + assert state.get_setting("pr_title_sessions") is False # opt-in only + state.set_setting("pr_title_sessions", True) + assert app_state.AppState().get_setting("pr_title_sessions") is True + + def test_caffeine_on_launch_setting(app_state): state = app_state.AppState() assert state.get_setting("caffeine_on_launch") is False # opt-in only diff --git a/tests/test_store.py b/tests/test_store.py index 4af3d7e..d8bd4b5 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -525,3 +525,88 @@ def test_rows_representing_holds_a_mid_chain_fork_during_a_second_handoff(store) store.record_forward(first_fork, second_fork) assert store.rows_representing(first_fork) == [original] assert store.rows_representing(second_fork) == [original] + + +# -- apply_pr_title (the pr_title_sessions setting) ------------------------- + + +def _titled_pr(number, title=None): + record = {"number": number, "url": f"https://github.com/episode6/collins/pull/{number}"} + if title: + record["title"] = title + return record + + +def test_apply_pr_title_is_off_by_default(store): + session_id = store._last_sessions[0].session_id + store.state.set_session_prs(session_id, [_titled_pr(55, "Add the thing")]) + store.apply_pr_title(session_id) + assert store.state.get_generated_name(session_id) is None + + +def test_apply_pr_title_renames_to_the_newest_titled_pr(store): + store.state.set_setting("pr_title_sessions", True) + session = store._last_sessions[0] + store.state.set_session_prs( + session.session_id, [_titled_pr(40, "Old work"), _titled_pr(55, "Add the thing")] + ) + store.apply_pr_title(session.session_id) + assert store.state.get_generated_name(session.session_id) == "Add the thing" + assert store.display_name(session) == "Add the thing" + + +def test_apply_pr_title_waits_for_a_title(store): + """A bare pr-link record has no title until gh answers; nothing to do yet.""" + store.state.set_setting("pr_title_sessions", True) + session_id = store._last_sessions[0].session_id + store.state.set_generated_name(session_id, "Auto title") + store.state.set_session_prs(session_id, [_titled_pr(55)]) + store.apply_pr_title(session_id) + assert store.state.get_generated_name(session_id) == "Auto title" + + +def test_apply_pr_title_loses_to_a_manual_rename(store): + store.state.set_setting("pr_title_sessions", True) + session = store._last_sessions[0] + store.rename(session.session_id, "My name") + store.state.set_session_prs(session.session_id, [_titled_pr(55, "Add the thing")]) + store.apply_pr_title(session.session_id) + # The generated slot is written, but the manual name keeps winning. + assert store.state.get_generated_name(session.session_id) == "Add the thing" + assert store.display_name(session) == "My name" + + +def test_apply_pr_titles_sweeps_saved_prs_when_switched_on(store): + """Turning the setting on retitles sessions whose PRs are already saved.""" + first, second = store._last_sessions[0], store._last_sessions[1] + store.state.set_session_prs(first.session_id, [_titled_pr(55, "Add the thing")]) + store.state.set_session_prs(second.session_id, [_titled_pr(56)]) # no title yet + store.apply_pr_titles() # setting still off: a no-op + assert store.state.get_generated_name(first.session_id) is None + + store.state.set_setting("pr_title_sessions", True) + store.apply_pr_titles() + assert store.state.get_generated_name(first.session_id) == "Add the thing" + assert store.state.get_generated_name(second.session_id) is None + + +def test_apply_pr_titles_sweeps_only_on_the_off_to_on_flip(store): + """Preferences apply calls the sweep on every save; only the save that + flips the setting on walks the sessions — steady-state names are the + per-detection apply_pr_title's job.""" + first = store._last_sessions[0] + store.state.set_setting("pr_title_sessions", True) + store.apply_pr_titles() # off at construction, on now: the flip + + # A list saved behind the sweep's back: a later steady-state apply (the + # user changed the font, say) must not pick it up. + store.state.set_session_prs(first.session_id, [_titled_pr(55, "Add the thing")]) + store.apply_pr_titles() + assert store.state.get_generated_name(first.session_id) is None + + # Off and back on is a fresh flip, and a fresh catch-up. + store.state.set_setting("pr_title_sessions", False) + store.apply_pr_titles() + store.state.set_setting("pr_title_sessions", True) + store.apply_pr_titles() + assert store.state.get_generated_name(first.session_id) == "Add the thing"