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
14 changes: 14 additions & 0 deletions collins/prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions collins/prstatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion collins/sidebar.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions collins/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 44 additions & 1 deletion collins/store.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion collins/window.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()):
Expand Down
10 changes: 10 additions & 0 deletions po/collins.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
26 changes: 26 additions & 0 deletions tests/test_prstatus.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
invalidate,
menu_name,
merge_ordered,
newest_title,
parse_pr_link,
refresh,
resync,
Expand Down Expand Up @@ -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) --------------------------


Expand Down
9 changes: 8 additions & 1 deletion tests/test_state.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"