diff --git a/pygittools/actions.py b/pygittools/actions.py
index 82cb515..73184a1 100644
--- a/pygittools/actions.py
+++ b/pygittools/actions.py
@@ -1,4 +1,4 @@
-"""Project actions: run named scripts from ``pyproject.toml`` in the project venv."""
+"""Project actions: run named scripts from ``pyproject.toml`` or ``actions.toml``."""
 
 from __future__ import annotations
 
@@ -13,12 +13,24 @@ from abcreg import AnnotatedABC  # type: ignore[import-untyped]
 
 _TOOL_TABLE = "pgt"
 _ACTIONS_KEY = "actions"
+_ACTIONS_FILE = "actions.toml"
+_DEFAULT_SET_NAME = "default"
+_FLAT_FILE_SET_NAME = "actions"
 
 
 @dataclass(frozen=True, slots=True)
 class ActionSpec:
 	name: str
 	script: str
+	args: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class ActionSet:
+	"""A named group of actions the TUI can cycle through."""
+
+	name: str
+	specs: tuple[ActionSpec, ...]
 
 
 class Action(AnnotatedABC):
@@ -29,60 +41,200 @@ class Action(AnnotatedABC):
 
 
 def find_project_root(start: Path) -> Path | None:
-	"""Walk upward from ``start`` looking for a ``pyproject.toml``."""
+	"""Walk upward for ``pyproject.toml``, else the nearest ``actions.toml``."""
 	current = start.resolve()
+	actions_root: Path | None = None
 	for path in [current, *current.parents]:
 		if (path / "pyproject.toml").is_file():
 			return path
-	return None
+		if actions_root is None and (path / _ACTIONS_FILE).is_file():
+			actions_root = path
+	return actions_root
 
 
 def load_action_specs(project_root: Path) -> list[ActionSpec]:
 	"""Read ``[tool.pgt].actions`` from the project's ``pyproject.toml``."""
+	pgt = _pyproject_pgt(project_root)
+	if pgt is None:
+		return []
+	raw = pgt.get(_ACTIONS_KEY)
+	if raw is None:
+		return []
+	return _parse_actions(raw, where="[tool.pgt].actions")
+
+
+def load_action_sets(project_root: Path) -> list[ActionSet]:
+	"""Load action sets from ``pyproject.toml`` and a root ``actions.toml``.
+
+	``[tool.pgt].actions`` is one set (named ``default``, or ``[tool.pgt].name``).
+	``[tool.pgt.sets.<name>]`` tables are further sets. ``actions.toml`` may repeat
+	that shape, and each other top-level table is also a set:
+
+	.. code-block:: toml
+
+	[quality]
+	actions = [
+		{ name = "lint", script = "scripts/lint.py" },
+	]
+	"""
+	sets = [*_pyproject_sets(project_root), *_actions_file_sets(project_root)]
+	seen: set[str] = set()
+	for action_set in sets:
+		if action_set.name in seen:
+			raise ValueError(f"Duplicate action set name: {action_set.name}")
+		seen.add(action_set.name)
+	return sets
+
+
+def _pyproject_pgt(project_root: Path) -> dict[str, object] | None:
 	pyproject = project_root / "pyproject.toml"
 	if not pyproject.is_file():
-		return []
+		return None
 	data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
 	tool = data.get("tool")
 	if not isinstance(tool, dict):
-		return []
+		return None
 	pgt = tool.get(_TOOL_TABLE)
 	if not isinstance(pgt, dict):
+		return None
+	return pgt
+
+
+def _pyproject_sets(project_root: Path) -> list[ActionSet]:
+	pgt = _pyproject_pgt(project_root)
+	if pgt is None:
 		return []
-	raw = pgt.get(_ACTIONS_KEY)
-	if raw is None:
-		return []
-	return _parse_actions(raw)
+	sets: list[ActionSet] = []
+	flat = _parse_flat_set(
+		pgt,
+		default_name=_DEFAULT_SET_NAME,
+		actions_where="[tool.pgt].actions",
+		name_where="[tool.pgt].name",
+	)
+	if flat is not None:
+		sets.append(flat)
+	raw_sets = pgt.get("sets")
+	if raw_sets is not None:
+		sets.extend(_parse_named_tables(raw_sets, where="[tool.pgt].sets"))
+	return sets
 
 
-def _parse_actions(raw: object) -> list[ActionSpec]:
+def _actions_file_sets(project_root: Path) -> list[ActionSet]:
+	path = project_root / _ACTIONS_FILE
+	if not path.is_file():
+		return []
+	data = tomllib.loads(path.read_text(encoding="utf-8"))
+	if not isinstance(data, dict):
+		raise ValueError("actions.toml must be a table")
+	sets: list[ActionSet] = []
+	actions_value = data.get(_ACTIONS_KEY)
+	consumed = {"name"}
+	if isinstance(actions_value, list):
+		consumed.add(_ACTIONS_KEY)
+		flat = _parse_flat_set(
+			data,
+			default_name=_FLAT_FILE_SET_NAME,
+			actions_where="actions.toml actions",
+			name_where="actions.toml name",
+		)
+		if flat is not None:
+			sets.append(flat)
+	elif "name" in data:
+		raise ValueError("actions.toml 'name' requires an 'actions' array")
+	for key, value in data.items():
+		if key in consumed:
+			continue
+		if not isinstance(key, str) or not key.strip():
+			raise ValueError("actions.toml set name must be a non-empty string")
+		if not isinstance(value, dict):
+			raise ValueError(f"actions.toml unexpected key {key!r}")
+		specs = _specs_from_set_table(value, where=f"actions.toml [{key.strip()}]")
+		if specs:
+			sets.append(ActionSet(name=key.strip(), specs=specs))
+	return sets
+
+
+def _parse_flat_set(
+	data: dict[str, object],
+	*,
+	default_name: str,
+	actions_where: str,
+	name_where: str,
+) -> ActionSet | None:
+	if _ACTIONS_KEY not in data:
+		if "name" in data:
+			raise ValueError(f"{name_where} requires 'actions'")
+		return None
+	raw_name = data.get("name", default_name)
+	if not isinstance(raw_name, str) or not raw_name.strip():
+		raise ValueError(f"{name_where} must be a non-empty string")
+	specs = tuple(_parse_actions(data[_ACTIONS_KEY], where=actions_where))
+	if not specs:
+		return None
+	return ActionSet(name=raw_name.strip(), specs=specs)
+
+
+def _parse_named_tables(raw: object, *, where: str) -> list[ActionSet]:
+	if not isinstance(raw, dict):
+		raise ValueError(f"{where} must be a table of action sets")
+	sets: list[ActionSet] = []
+	for key, value in raw.items():
+		if not isinstance(key, str) or not key.strip():
+			raise ValueError(f"{where} set name must be a non-empty string")
+		if not isinstance(value, dict):
+			raise ValueError(f"{where}.{key} must be a table")
+		name = key.strip()
+		specs = _specs_from_set_table(value, where=f"{where}.{name}")
+		if specs:
+			sets.append(ActionSet(name=name, specs=specs))
+	return sets
+
+
+def _specs_from_set_table(value: dict[str, object], *, where: str) -> tuple[ActionSpec, ...]:
+	if _ACTIONS_KEY not in value:
+		raise ValueError(f"{where} missing 'actions'")
+	return tuple(_parse_actions(value[_ACTIONS_KEY], where=f"{where}.actions"))
+
+
+def _parse_actions(raw: object, *, where: str) -> list[ActionSpec]:
 	if not isinstance(raw, list):
-		raise ValueError("[tool.pgt].actions must be an array of name/script entries")
+		raise ValueError(f"{where} must be an array of name/script entries")
 	specs: list[ActionSpec] = []
 	for index, entry in enumerate(raw):
-		specs.append(_parse_action_entry(entry, index))
+		specs.append(_parse_action_entry(entry, where=f"{where}[{index}]"))
 	return specs
 
 
-def _parse_action_entry(entry: object, index: int) -> ActionSpec:
+def _parse_action_entry(entry: object, *, where: str) -> ActionSpec:
 	if isinstance(entry, dict):
 		name = entry.get("name")
 		script = entry.get("script")
 		if not isinstance(name, str) or not name.strip():
-			raise ValueError(f"[tool.pgt].actions[{index}] missing string 'name'")
+			raise ValueError(f"{where} missing string 'name'")
 		if not isinstance(script, str) or not script.strip():
-			raise ValueError(f"[tool.pgt].actions[{index}] missing string 'script'")
-		return ActionSpec(name=name.strip(), script=script.strip())
+			raise ValueError(f"{where} missing string 'script'")
+		return ActionSpec(name=name.strip(), script=script.strip(), args=_parse_args(entry.get("args"), where=where))
 	if isinstance(entry, list) and len(entry) == 2:
 		name, script = entry
 		if not isinstance(name, str) or not name.strip():
-			raise ValueError(f"[tool.pgt].actions[{index}] name must be a non-empty string")
+			raise ValueError(f"{where} name must be a non-empty string")
 		if not isinstance(script, str) or not script.strip():
-			raise ValueError(f"[tool.pgt].actions[{index}] script must be a non-empty string")
+			raise ValueError(f"{where} script must be a non-empty string")
 		return ActionSpec(name=name.strip(), script=script.strip())
-	raise ValueError(
-		f"[tool.pgt].actions[{index}] must be {{name=..., script=...}} or [name, script]",
-	)
+	raise ValueError(f"{where} must be {{name=..., script=...}} or [name, script]")
+
+
+def _parse_args(raw: object, *, where: str) -> tuple[str, ...]:
+	if raw is None:
+		return ()
+	if not isinstance(raw, list):
+		raise ValueError(f"{where} 'args' must be an array of strings")
+	args: list[str] = []
+	for index, item in enumerate(raw):
+		if not isinstance(item, str) or item == "":
+			raise ValueError(f"{where} args[{index}] must be a non-empty string")
+		args.append(item)
+	return tuple(args)
 
 
 def project_venv_python(project_root: Path) -> Path | None:
@@ -95,7 +247,7 @@ def project_venv_python(project_root: Path) -> Path | None:
 	return None
 
 
-def run_action_script(project_root: Path, script: str) -> tuple[bool, str]:
+def run_action_script(project_root: Path, script: str, args: tuple[str, ...] = ()) -> tuple[bool, str]:
 	"""Execute ``script`` inside the project's ``.venv`` and return ``(ok, message)``."""
 	raw_script = Path(script)
 	script_path = raw_script.resolve() if raw_script.is_absolute() else (project_root / raw_script).resolve()
@@ -114,11 +266,11 @@ def run_action_script(project_root: Path, script: str) -> tuple[bool, str]:
 	env.pop("PYTHONHOME", None)
 
 	if script_path.suffix == ".py":
-		cmd = [str(venv_python), str(script_path)]
+		cmd = [str(venv_python), str(script_path), *args]
 	elif os.access(script_path, os.X_OK):
-		cmd = [str(script_path)]
+		cmd = [str(script_path), *args]
 	else:
-		cmd = ["bash", str(script_path)]
+		cmd = ["bash", str(script_path), *args]
 
 	proc = subprocess.run(
 		cmd,
@@ -146,7 +298,10 @@ def _result_message(stdout: str, stderr: str) -> str:
 
 @Action.register
 class ProjectActions:
-	"""Default action runner backed by ``[tool.pgt].actions``."""
+	"""Default action runner backed by ``[tool.pgt].actions``.
+
+	Pass ``specs`` to run one set loaded by :func:`load_action_sets`.
+	"""
 
 	def __init__(self, project_root: Path, specs: list[ActionSpec] | None = None) -> None:
 		self._root = project_root.resolve()
@@ -160,7 +315,7 @@ class ProjectActions:
 		spec = self._specs.get(name)
 		if spec is None:
 			return False, f"Unknown action: {name}"
-		return run_action_script(self._root, spec.script)
+		return run_action_script(self._root, spec.script, spec.args)
 
 
 def project_actions(project_root: Path) -> ProjectActions:
diff --git a/pygittools/actions_test.py b/pygittools/actions_test.py
index 1b5c910..ddcb86e 100644
--- a/pygittools/actions_test.py
+++ b/pygittools/actions_test.py
@@ -10,9 +10,11 @@ import pytest
 
 from pygittools.actions import (
 	Action,
+	ActionSet,
 	ActionSpec,
 	ProjectActions,
 	find_project_root,
+	load_action_sets,
 	load_action_specs,
 	project_actions,
 	project_venv_python,
@@ -40,10 +42,18 @@ def test_find_project_root(tmp_path: Path) -> None:
 	nested = tmp_path / "a" / "b"
 	nested.mkdir(parents=True)
 	(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8")
+	(nested / "actions.toml").write_text("[local]\nactions = []\n", encoding="utf-8")
 	assert find_project_root(nested) == tmp_path.resolve()
 	assert find_project_root(tmp_path) == tmp_path.resolve()
 
 
+def test_find_project_root_actions_toml_only(tmp_path: Path) -> None:
+	nested = tmp_path / "a" / "b"
+	nested.mkdir(parents=True)
+	(tmp_path / "actions.toml").write_text("[quality]\nactions = []\n", encoding="utf-8")
+	assert find_project_root(nested) == tmp_path.resolve()
+
+
 def test_load_action_specs_inline_tables(tmp_path: Path) -> None:
 	_write_pyproject(
 		tmp_path,
@@ -65,12 +75,133 @@ def test_load_action_specs_pair_arrays(tmp_path: Path) -> None:
 	assert [s.name for s in specs] == ["lint", "test"]
 
 
+def test_load_action_specs_args(tmp_path: Path) -> None:
+	_write_pyproject(
+		tmp_path,
+		'[{ name = "py", script = "scripts/pytest_version.py", args = ["3.12", "-q"] }]',
+	)
+	specs = load_action_specs(tmp_path)
+	assert specs == [
+		ActionSpec(name="py", script="scripts/pytest_version.py", args=("3.12", "-q")),
+	]
+
+
+def test_workspace_python_version_set() -> None:
+	root = Path(__file__).resolve().parents[1]
+	sets = {item.name: item for item in load_action_sets(root)}
+	versions = sets["testversions"]
+	assert [spec.args for spec in versions.specs] == [("3.11",), ("3.12",), ("3.13",), ("3.14",)]
+	assert {spec.script for spec in versions.specs} == {"scripts/pytest_version.py"}
+
+
+def test_load_action_specs_invalid_args(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '[{ name = "py", script = "x.py", args = "3.12" }]')
+	with pytest.raises(ValueError, match="args"):
+		load_action_specs(tmp_path)
+
+
+def test_run_action_script_passes_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	script = tmp_path / "echo_args.py"
+	script.write_text("print('unused')\n", encoding="utf-8")
+	bin_dir = tmp_path / ".venv" / "bin"
+	win_dir = tmp_path / ".venv" / "Scripts"
+	bin_dir.mkdir(parents=True)
+	win_dir.mkdir()
+	(bin_dir / "python").write_text("", encoding="utf-8")
+	(win_dir / "python.exe").write_text("", encoding="utf-8")
+	captured: list[list[str]] = []
+
+	class _Proc:
+		returncode = 0
+		stdout = "ok"
+		stderr = ""
+
+	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+		del kwargs
+		captured.append(list(cmd))
+		return _Proc()
+
+	monkeypatch.setattr("pygittools.actions.subprocess.run", fake_run)
+	spec = ActionSpec(name="py", script="echo_args.py", args=("3.12", "-q"))
+	ok, message = ProjectActions(tmp_path, [spec]).action("py")
+	python = project_venv_python(tmp_path)
+	assert python is not None
+	assert ok is True
+	assert message == "ok"
+	assert captured == [[str(python), str(script.resolve()), "3.12", "-q"]]
+
+
 def test_load_action_specs_invalid(tmp_path: Path) -> None:
 	_write_pyproject(tmp_path, '{ name = "lint" }')
 	with pytest.raises(ValueError, match="must be an array"):
 		load_action_specs(tmp_path)
 
 
+def test_load_action_sets_from_pyproject_and_actions_toml(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '[{ name = "lint", script = "scripts/lint.py" }]')
+	pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
+	(tmp_path / "pyproject.toml").write_text(
+		pyproject
+		+ """
+[tool.pgt.sets.release]
+actions = [
+  ["build", "scripts/build.py"],
+]
+""",
+		encoding="utf-8",
+	)
+	(tmp_path / "actions.toml").write_text(
+		"""
+name = "tools"
+
+[[actions]]
+name = "fmt"
+script = "scripts/fmt.py"
+
+[quality]
+actions = [
+  { name = "test", script = "scripts/test.py" },
+]
+""",
+		encoding="utf-8",
+	)
+	assert load_action_sets(tmp_path) == [
+		ActionSet(name="default", specs=(ActionSpec(name="lint", script="scripts/lint.py"),)),
+		ActionSet(name="release", specs=(ActionSpec(name="build", script="scripts/build.py"),)),
+		ActionSet(name="tools", specs=(ActionSpec(name="fmt", script="scripts/fmt.py"),)),
+		ActionSet(name="quality", specs=(ActionSpec(name="test", script="scripts/test.py"),)),
+	]
+
+
+def test_load_action_sets_named_pyproject_flat_set(tmp_path: Path) -> None:
+	(tmp_path / "pyproject.toml").write_text(
+		"""
+[tool.pgt]
+name = "checks"
+actions = [{ name = "lint", script = "lint.py" }]
+""",
+		encoding="utf-8",
+	)
+	sets = load_action_sets(tmp_path)
+	assert [item.name for item in sets] == ["checks"]
+
+
+def test_load_action_sets_duplicate_name(tmp_path: Path) -> None:
+	_write_pyproject(tmp_path, '[{ name = "lint", script = "lint.py" }]')
+	(tmp_path / "actions.toml").write_text(
+		'[default]\nactions = [{ name = "other", script = "other.py" }]\n',
+		encoding="utf-8",
+	)
+	with pytest.raises(ValueError, match="Duplicate action set name: default"):
+		load_action_sets(tmp_path)
+
+
+def test_load_action_sets_invalid_table(tmp_path: Path) -> None:
+	(tmp_path / "actions.toml").write_text('quality = "nope"\n', encoding="utf-8")
+	with pytest.raises(ValueError, match="unexpected key"):
+		load_action_sets(tmp_path)
+
+
 def test_project_actions_is_action(tmp_path: Path) -> None:
 	_write_pyproject(tmp_path, '[{ name = "ok", script = "ok.py" }]')
 	runner = project_actions(tmp_path)
diff --git a/pygittools/help.md b/pygittools/help.md
index 8c80bdd..8eef8c8 100644
--- a/pygittools/help.md
+++ b/pygittools/help.md
@@ -7,31 +7,42 @@ PyGitTools terminal UI for browsing a local git repository.
 The top line shows the project name followed by **Status** and **Log** tabs. The active tab is highlighted in yellow.
 
 - **H** / **Left** or **L** / **Right** — switch between **Status** and **Log** (anywhere except while typing a commit message)
-- **J** / **Up** — move selection up (from the changes or log list, moves to the branch row, then the project row)
+- **J** / **Up** — move selection up (from the changes list, to the actions row, then the branch row, then the project row)
 - **K** / **Down** — move selection down
 - **Project row** — **Enter** opens the projects table (PyGitWeb `PROJECTROOT`)
 - **Branch row** — **Enter** opens a filterable list of local branches to check out
+- **Actions row** — always on **Status**. **Enter** opens the actions page when the project has action sets; otherwise the row reads **No Action Groups**
 - **Commit row** — type a message, then **Enter** to commit staged changes (runs git hooks); **Enter** with an empty message starts typing
 - **Space** — collapse or expand a section header
 - **Enter** on a section header — stage or unstage every file in that section (on **Staged**, Enter stages all unstaged and untracked files when nothing is staged)
 - **Enter** on a file — stage (unstaged / untracked) or unstage (staged)
 - **Log tab** — **J** / **K** scroll the commit history (newest first)
 - **?** — open this help page
-- **!** — open the actions page (scripts from ``[tool.pgt].actions``)
+- **!** — open the actions page (scripts from ``pyproject.toml`` or ``actions.toml``)
 - **Q** — quit
 
 Commit or hook failures appear in the status bar.
 
 ## Actions page
 
-Lists project actions declared in ``pyproject.toml`` under ``[tool.pgt].actions``
-(an array of ``{ name = "...", script = "..." }`` entries). Scripts run inside the
-project's ``.venv``.
+Lists project actions from ``pyproject.toml`` and a top-level ``actions.toml``.
+``[tool.pgt].actions`` is the ``default`` set (rename it with ``[tool.pgt].name``).
+More sets live in ``[tool.pgt.sets.<name>]`` or as tables in ``actions.toml``.
+Each action is ``{ name = "...", script = "..." }`` and may include ``args = ["..."]``.
+Scripts run inside the
+project's ``.venv``. An ``actions.toml`` set looks like this:
+
+    [quality]
+    actions = [
+      { name = "lint", script = "scripts/lint.py" },
+    ]
 
 - **J** / **Up** — move selection up
 - **K** / **Down** — move selection down
+- **H** / **Left** or **L** / **Right** — previous or next action set
+- **Tab** / **Shift-Tab** — next or previous action set
 - **Enter** — run the selected action (or **Run all**)
-- **A** — run all actions in order, stopping at the first failure
+- **A** — run all actions in the current set, stopping at the first failure
 - **Esc** / **Q** — return to the home page
 
 While an action runs, its row shows an animated spinner. Completed actions use the
diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
index 5a895d3..ecdce9a 100644
--- a/pygittools/tui/input_context.py
+++ b/pygittools/tui/input_context.py
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
 	from pygittools.tui.tasks_list import TasksBoardList
 	from pygittools.tui.worktrees_list import WorktreesList
 
-_HeaderFocus = Literal["changes", "repo", "branch"]
+_HeaderFocus = Literal["changes", "repo", "branch", "actions"]
 _HomeTab = Literal["status", "log", "tasks", "worktrees"]
 
 
@@ -55,6 +55,9 @@ class PageInputHost(Protocol):
 	@property
 	def worktrees(self) -> WorktreesList: ...
 
+	@property
+	def has_actions(self) -> bool: ...
+
 
 class NavContext:
 	def status_hint(self, host: PageInputHost) -> str:
@@ -66,6 +69,10 @@ class NavContext:
 			if host.active_tab == "worktrees":
 				return "Enter add worktree — j/k move — H/L switch tab"
 			return "Enter switch branch — j/k move — H/L switch tab"
+		if host.header_focus == "actions":
+			if host.has_actions:
+				return "Enter open actions — j/k move — H/L switch tab"
+			return "No action groups — j/k move — H/L switch tab"
 		if host.active_tab == "log":
 			return host.log.status_hint()
 		if host.active_tab == "tasks":
@@ -94,7 +101,7 @@ class NavContext:
 			if is_up(key):
 				host.set_header_focus("repo")
 			elif is_down(key):
-				host.set_header_focus("changes")
+				host.set_header_focus("actions" if _show_actions_row(host) else "changes")
 			elif is_enter(key):
 				if host.active_tab == "tasks" and host.page_context is not None:
 					host.tasks.sync_with_default_remote(host.page_context.repo)
@@ -108,8 +115,17 @@ class NavContext:
 				)
 			return ContextDispatch()
 
+		if host.header_focus == "actions":
+			if is_up(key):
+				host.set_header_focus("branch")
+			elif is_down(key):
+				host.set_header_focus("changes")
+			elif is_enter(key) and host.has_actions:
+				return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()))
+			return ContextDispatch()
+
 		if is_up(key) and _content_list_is_at_top(host):
-			host.set_header_focus("branch")
+			host.set_header_focus("actions" if _show_actions_row(host) else "branch")
 			return ContextDispatch()
 
 		ctx = host.page_context
@@ -157,6 +173,10 @@ def input_context() -> InputContext:
 	return InputContext()
 
 
+def _show_actions_row(host: PageInputHost) -> bool:
+	return host.active_tab == "status"
+
+
 def _content_list_is_at_top(host: PageInputHost) -> bool:
 	if host.active_tab == "log":
 		return host.log.is_at_top()
diff --git a/pygittools/tui/input_context_test.py b/pygittools/tui/input_context_test.py
index 0106d81..b7f206a 100644
--- a/pygittools/tui/input_context_test.py
+++ b/pygittools/tui/input_context_test.py
@@ -17,7 +17,7 @@ from pygittools.tui.tasks_list import TasksBoardList
 from pygittools.tui.types import PageAction, PageContext
 from pygittools.tui.worktrees_list import WorktreesList
 
-_HeaderFocus = Literal["changes", "repo", "branch"]
+_HeaderFocus = Literal["changes", "repo", "branch", "actions"]
 _HomeTab = Literal["status", "log", "tasks", "worktrees"]
 
 
@@ -30,6 +30,7 @@ class FakeHost:
 	worktrees: WorktreesList
 	header_focus: _HeaderFocus = "changes"
 	active_tab: _HomeTab = "status"
+	has_actions: bool = False
 
 	def set_header_focus(self, focus: _HeaderFocus) -> None:
 		self.header_focus = focus
@@ -94,6 +95,9 @@ def test_nav_context_branch_header(host: FakeHost) -> None:
 	nav.dispatch_key(host, key("Down"))
 	assert host.header_focus == "branch"
 	nav.dispatch_key(host, key("Down"))
+	assert host.header_focus == "actions"
+	assert "No action groups" in nav.status_hint(host)
+	nav.dispatch_key(host, key("Down"))
 	assert host.header_focus == "changes"
 	host.header_focus = "branch"
 	dispatch = nav.dispatch_key(host, key("Enter"))
@@ -111,9 +115,32 @@ def test_nav_context_tab_switch(host: FakeHost) -> None:
 	assert host.active_tab == "log"
 
 
+def test_nav_context_actions_row(host: FakeHost) -> None:
+	host.has_actions = True
+	nav = nav_context()
+	nav.dispatch_key(host, key("Up"))
+	assert host.header_focus == "actions"
+	assert "open actions" in nav.status_hint(host)
+	nav.dispatch_key(host, key("Up"))
+	assert host.header_focus == "branch"
+	nav.dispatch_key(host, key("Down"))
+	assert host.header_focus == "actions"
+	dispatch = nav.dispatch_key(host, key("Enter"))
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.PUSH
+	assert isinstance(dispatch.page_result.next_page, ActionsPage)
+	host.header_focus = "actions"
+	nav.dispatch_key(host, key("Down"))
+	assert host.header_focus == "changes"
+
+
 def test_nav_context_moves_to_branch_from_top(host: FakeHost) -> None:
 	host.changes._list_state.select(0)
-	nav_context().dispatch_key(host, key("Up"))
+	nav = nav_context()
+	nav.dispatch_key(host, key("Up"))
+	assert host.header_focus == "actions"
+	assert nav.dispatch_key(host, key("Enter")).page_result is None
+	nav.dispatch_key(host, key("Up"))
 	assert host.header_focus == "branch"
 
 
diff --git a/pygittools/tui/keys.py b/pygittools/tui/keys.py
index d5425e9..b1b7e49 100644
--- a/pygittools/tui/keys.py
+++ b/pygittools/tui/keys.py
@@ -63,6 +63,14 @@ def is_esc(ev: KeyLike) -> bool:
 	return ev.code == "Esc"
 
 
+def is_tab(ev: KeyLike) -> bool:
+	return ev.code == "Tab" and not ev.shift
+
+
+def is_backtab(ev: KeyLike) -> bool:
+	return ev.code in {"BackTab", "Backtab"} or (ev.code == "Tab" and ev.shift)
+
+
 def is_backspace(ev: KeyLike) -> bool:
 	return ev.code == "Backspace"
 
diff --git a/pygittools/tui/pages/actions.py b/pygittools/tui/pages/actions.py
index a4230f1..1d0b67a 100644
--- a/pygittools/tui/pages/actions.py
+++ b/pygittools/tui/pages/actions.py
@@ -1,4 +1,4 @@
-"""Project actions page: run scripts defined in ``pyproject.toml``."""
+"""Project actions page: run scripts from ``pyproject.toml`` or ``actions.toml``."""
 
 from __future__ import annotations
 
@@ -20,9 +20,21 @@ from pyratatui import (
 	Style,
 )
 
-from pygittools.actions import ActionSpec, ProjectActions, find_project_root, load_action_specs
+from pygittools.actions import ActionSet, ActionSpec, ProjectActions, find_project_root, load_action_sets
 from pygittools.tui.git_colors import current_theme, focus_style
-from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
+from pygittools.tui.keys import (
+	KeyLike,
+	is_backtab,
+	is_down,
+	is_enter,
+	is_esc,
+	is_left,
+	is_quit,
+	is_right,
+	is_tab,
+	is_up,
+)
+from pygittools.tui.tabs import render_tab_row
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 _SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
@@ -50,11 +62,13 @@ class ActionsPage:
 	def __init__(self) -> None:
 		self._ctx: PageContext | None = None
 		self._project_root: Path | None = None
-		self._specs: list[ActionSpec] = []
+		self._sets: list[ActionSet] = []
+		self._set_index = 0
 		self._list_state = ListState()
-		self._states: dict[str, _RunState] = {}
-		self._messages: dict[str, str] = {}
+		self._states: dict[tuple[str, str], _RunState] = {}
+		self._messages: dict[tuple[str, str], str] = {}
 		self._active: str | None = None
+		self._active_set: str | None = None
 		self._spinner_index = 0
 		self._last_spin_at = 0.0
 		self._error: str | None = None
@@ -65,14 +79,23 @@ class ActionsPage:
 	def on_enter(self, ctx: PageContext) -> None:
 		self._ctx = ctx
 		self._stop_worker()
+		self._error = None
 		workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else _worktree_path(ctx.repo_path)
 		self._project_root = find_project_root(workdir)
-		self._specs = load_action_specs(self._project_root) if self._project_root is not None else []
-		self._states = {spec.name: _RunState.IDLE for spec in self._specs}
+		self._sets = []
+		self._set_index = 0
+		if self._project_root is not None:
+			try:
+				self._sets = load_action_sets(self._project_root)
+			except (OSError, ValueError) as exc:
+				self._error = str(exc)
+		self._states = {
+			(action_set.name, spec.name): _RunState.IDLE for action_set in self._sets for spec in action_set.specs
+		}
 		self._messages = {}
 		self._active = None
+		self._active_set = None
 		self._spinner_index = 0
-		self._error = None
 		self._list_state.select(0)
 
 	def on_idle(self) -> None:
@@ -90,25 +113,36 @@ class ActionsPage:
 			return self._error
 		with self._lock:
 			active = self._active
+			active_set = self._active_set
 			if active is not None:
-				msg = self._messages.get(active, "")
+				msg = self._messages.get((active_set or "", active), "")
 				suffix = f" — {msg}" if msg else ""
 				return f"Running {active}…{suffix}"
 		if self._project_root is None:
-			return "No pyproject.toml found — Esc back"
-		if not self._specs:
-			return "No [tool.pgt].actions configured — Esc back"
-		return "Enter run — A run all — j/k move — Esc back"
+			return "No pyproject.toml or actions.toml found — Esc back"
+		if not self._sets:
+			return "No actions configured — Esc back"
+		hint = "Enter run — A run all — j/k move — Esc back"
+		if len(self._sets) > 1:
+			return f"H/L or Tab/Shift-Tab sets — {hint}"
+		return hint
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0:
 			return
 		theme = current_theme()
-		chunks = (
-			Layout().direction(Direction.Vertical).constraints([Constraint.length(1), Constraint.fill(1)]).split(area)
-		)
+		multi = len(self._sets) > 1
+		constraints = [Constraint.length(1), Constraint.fill(1)]
+		if multi:
+			constraints = [Constraint.length(1), Constraint.length(1), Constraint.fill(1)]
+		chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
 		root_label = str(self._project_root) if self._project_root is not None else "(none)"
 		frame.render_widget(Paragraph.from_string(f"Actions ({root_label})").style(theme.header), chunks[0])
+		list_area = chunks[1]
+		if multi:
+			labels = tuple(action_set.name for action_set in self._sets)
+			render_tab_row(frame, chunks[1], "", labels, self._set_index)
+			list_area = chunks[2]
 
 		rows = self._rows()
 		selected = self._list_state.selected or 0
@@ -121,7 +155,7 @@ class ActionsPage:
 			items.append(ListItem("No actions configured", theme.log_date))
 		frame.render_stateful_list(
 			List(items).highlight_style(theme.focus).highlight_symbol(""),
-			chunks[1],
+			list_area,
 			self._list_state,
 		)
 
@@ -136,6 +170,13 @@ class ActionsPage:
 		if busy:
 			return PageResult()
 
+		if is_backtab(key) or is_left(key):
+			self._cycle_set(-1)
+			return PageResult()
+		if is_tab(key) or is_right(key):
+			self._cycle_set(1)
+			return PageResult()
+
 		rows = self._rows()
 		if not rows:
 			return PageResult()
@@ -148,37 +189,58 @@ class ActionsPage:
 			selected = self._list_state.selected or 0
 			self._list_state.select(min(len(rows) - 1, selected + 1))
 			return PageResult()
-		if key.code.casefold() == "a" and self._specs:
-			self._start_run([spec.name for spec in self._specs], stop_on_failure=True)
+		specs = self._current_specs()
+		if key.code.casefold() == "a" and specs:
+			self._start_run([spec.name for spec in specs], stop_on_failure=True)
 			return PageResult()
 		if is_enter(key):
 			selected = self._list_state.selected or 0
 			row_key, _ = rows[selected]
 			if row_key == "__all__":
-				self._start_run([spec.name for spec in self._specs], stop_on_failure=True)
+				self._start_run([spec.name for spec in specs], stop_on_failure=True)
 			else:
 				self._start_run([row_key], stop_on_failure=False)
 			return PageResult()
 		return PageResult()
 
+	def _current_specs(self) -> list[ActionSpec]:
+		if not self._sets:
+			return []
+		return list(self._sets[self._set_index].specs)
+
+	def _current_set_name(self) -> str:
+		if not self._sets:
+			return ""
+		return self._sets[self._set_index].name
+
+	def _cycle_set(self, delta: int) -> None:
+		count = len(self._sets)
+		if count < 2:
+			return
+		self._set_index = (self._set_index + delta) % count
+		self._list_state.select(0)
+
 	def _rows(self) -> list[tuple[str, str]]:
-		if not self._specs:
+		specs = self._current_specs()
+		if not specs:
 			return []
+		set_name = self._current_set_name()
 		rows: list[tuple[str, str]] = [("__all__", _RUN_ALL_LABEL)]
-		for spec in self._specs:
-			message = self._messages.get(spec.name, "")
+		for spec in specs:
+			message = self._messages.get((set_name, spec.name), "")
 			label = spec.name if not message else f"{spec.name} — {message}"
 			rows.append((spec.name, label))
 		return rows
 
 	def _marker_for(self, key: str) -> str:
+		set_name = self._current_set_name()
 		with self._lock:
 			if key == "__all__":
-				if self._active is not None:
+				if self._active is not None and self._active_set == set_name:
 					return _SPINNER[self._spinner_index]
 				return "▶"
-			state = self._states.get(key, _RunState.IDLE)
-			if state is _RunState.RUNNING or key == self._active:
+			state = self._states.get((set_name, key), _RunState.IDLE)
+			if state is _RunState.RUNNING or (key == self._active and self._active_set == set_name):
 				return _SPINNER[self._spinner_index]
 			if state is _RunState.SUCCESS:
 				return "✓"
@@ -188,16 +250,18 @@ class ActionsPage:
 
 	def _style_for(self, key: str, *, focused: bool) -> Style:
 		theme = current_theme()
+		set_name = self._current_set_name()
 		with self._lock:
 			if key == "__all__":
 				base = theme.header
 			else:
-				state = self._states.get(key, _RunState.IDLE)
+				state = self._states.get((set_name, key), _RunState.IDLE)
+				running = state is _RunState.RUNNING or (key == self._active and self._active_set == set_name)
 				if state is _RunState.SUCCESS:
 					base = theme.staged
 				elif state is _RunState.FAILURE:
 					base = theme.unstaged
-				elif state is _RunState.RUNNING or key == self._active:
+				elif running:
 					base = theme.log_hash
 				else:
 					base = theme.normal
@@ -206,21 +270,23 @@ class ActionsPage:
 	def _start_run(self, names: list[str], *, stop_on_failure: bool) -> None:
 		if not names or self._project_root is None:
 			return
+		set_name = self._current_set_name()
+		specs = self._current_specs()
 		self._stop_worker()
 		with self._lock:
 			for name in names:
-				self._states[name] = _RunState.IDLE
-				self._messages.pop(name, None)
+				self._states[(set_name, name)] = _RunState.IDLE
+				self._messages.pop((set_name, name), None)
 			self._active = names[0]
-			self._states[names[0]] = _RunState.RUNNING
+			self._active_set = set_name
+			self._states[(set_name, names[0])] = _RunState.RUNNING
 			self._spinner_index = 0
 			self._last_spin_at = time.monotonic()
 		root = self._project_root
-		specs = list(self._specs)
 		self._stop.clear()
 		self._worker = threading.Thread(
 			target=self._run_worker,
-			args=(root, specs, names, stop_on_failure),
+			args=(root, specs, names, set_name, stop_on_failure),
 			daemon=True,
 		)
 		self._worker.start()
@@ -230,6 +296,7 @@ class ActionsPage:
 		root: Path,
 		specs: list[ActionSpec],
 		names: list[str],
+		set_name: str,
 		stop_on_failure: bool,
 	) -> None:
 		runner = ProjectActions(root, specs)
@@ -238,15 +305,17 @@ class ActionsPage:
 				break
 			with self._lock:
 				self._active = name
-				self._states[name] = _RunState.RUNNING
+				self._active_set = set_name
+				self._states[(set_name, name)] = _RunState.RUNNING
 			ok, message = runner.action(name)
 			with self._lock:
-				self._states[name] = _RunState.SUCCESS if ok else _RunState.FAILURE
-				self._messages[name] = message
+				self._states[(set_name, name)] = _RunState.SUCCESS if ok else _RunState.FAILURE
+				self._messages[(set_name, name)] = message
 			if not ok and stop_on_failure:
 				break
 		with self._lock:
 			self._active = None
+			self._active_set = None
 
 	def _stop_worker(self) -> None:
 		self._stop.set()
@@ -256,6 +325,7 @@ class ActionsPage:
 		self._worker = None
 		with self._lock:
 			self._active = None
+			self._active_set = None
 
 
 def actions_page() -> ActionsPage:
diff --git a/pygittools/tui/pages/actions_test.py b/pygittools/tui/pages/actions_test.py
index 375086d..8a59f48 100644
--- a/pygittools/tui/pages/actions_test.py
+++ b/pygittools/tui/pages/actions_test.py
@@ -13,7 +13,7 @@ from pyratatui import Rect
 
 from pygittools.tui.git_colors import init_tui_color_theme
 from pygittools.tui.keys import key
-from pygittools.tui.pages.actions import ActionsPage, actions_page
+from pygittools.tui.pages.actions import ActionsPage, _RunState, actions_page
 from pygittools.tui.types import PageAction, PageContext
 
 
@@ -60,14 +60,11 @@ def test_actions_page_lists_and_run(tmp_path: Path) -> None:
 	while time.monotonic() < deadline:
 		page.on_idle()
 		with page._lock:
-			if page._active is None and page._states.get("ok") is not None:
-				from pygittools.tui.pages.actions import _RunState
-
-				if page._states["ok"] is not _RunState.IDLE:
-					break
+			if page._active is None and page._states.get(("default", "ok")) is not _RunState.IDLE:
+				break
 		time.sleep(0.05)
-	assert page._states["ok"].name == "SUCCESS"
-	assert page._messages["ok"] == "done"
+	assert page._states[("default", "ok")].name == "SUCCESS"
+	assert page._messages[("default", "ok")] == "done"
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
 
@@ -94,11 +91,9 @@ def test_actions_page_run_all_stops_on_failure(tmp_path: Path) -> None:
 			if page._active is None:
 				break
 		time.sleep(0.05)
-	from pygittools.tui.pages.actions import _RunState
-
-	assert page._states["bad"] is _RunState.FAILURE
-	assert page._states["good"] is _RunState.IDLE
-	assert "good" not in page._messages
+	assert page._states[("default", "bad")] is _RunState.FAILURE
+	assert page._states[("default", "good")] is _RunState.IDLE
+	assert ("default", "good") not in page._messages
 
 
 @pytest.mark.skipif(sys.platform == "win32", reason="symlink venv setup requires SeCreateSymbolicLinkPrivilege")
@@ -108,3 +103,77 @@ def test_actions_page_opens_from_factory(tmp_path: Path) -> None:
 	page.on_enter(ctx)
 	assert page.title == "actions"
 	assert isinstance(ctx.repo, Repository)
+	assert "No actions configured" in page.status_text()
+
+
+def _repo_dir(tmp_path: Path) -> tuple[PageContext, Path]:
+	repo_dir = tmp_path / "repo"
+	repo_dir.mkdir()
+	repo = init_repository(str(repo_dir), bare=False)
+	return PageContext(repo=repo, repo_path=Path(repo.path)), repo_dir
+
+
+def test_actions_page_cycles_sets(tmp_path: Path) -> None:
+	ctx, repo_dir = _repo_dir(tmp_path)
+	(repo_dir / "pyproject.toml").write_text(
+		'[project]\nname = "demo"\n\n[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n',
+		encoding="utf-8",
+	)
+	(repo_dir / "actions.toml").write_text(
+		'[release]\nactions = [{ name = "build", script = "build.py" }]\n',
+		encoding="utf-8",
+	)
+	page = ActionsPage()
+	page.on_enter(ctx)
+	assert "Tab/Shift-Tab" in page.status_text()
+	assert [label for _, label in page._rows()] == ["Run all", "lint"]
+	page.handle_key(key("Down"))
+	page.handle_key(key("Tab"))
+	assert [label for _, label in page._rows()] == ["Run all", "build"]
+	assert page._list_state.selected == 0
+	page.handle_key(key("Tab", shift=True))
+	assert [label for _, label in page._rows()] == ["Run all", "lint"]
+	page.handle_key(key("BackTab"))
+	assert [label for _, label in page._rows()] == ["Run all", "build"]
+	page.handle_key(key("Left"))
+	assert [label for _, label in page._rows()] == ["Run all", "lint"]
+	page.handle_key(key("Right"))
+	assert [label for _, label in page._rows()] == ["Run all", "build"]
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+
+
+def test_actions_page_run_all_uses_current_set(tmp_path: Path) -> None:
+	ctx, repo_dir = _repo_dir(tmp_path)
+	(repo_dir / "actions.toml").write_text(
+		"""
+[first]
+actions = [{ name = "one", script = "one.py" }]
+
+[second]
+actions = [{ name = "two", script = "two.py" }]
+""",
+		encoding="utf-8",
+	)
+	page = ActionsPage()
+	page.on_enter(ctx)
+	page.handle_key(key("Tab"))
+	page.handle_key(key("a"))
+	deadline = time.monotonic() + 5.0
+	while time.monotonic() < deadline:
+		page.on_idle()
+		with page._lock:
+			if page._active is None:
+				break
+		time.sleep(0.05)
+	assert page._states[("first", "one")] is _RunState.IDLE
+	assert page._states[("second", "two")] is _RunState.FAILURE
+	assert "Script not found" in page._messages[("second", "two")]
+
+
+def test_actions_page_bad_actions_toml(tmp_path: Path) -> None:
+	ctx, repo_dir = _repo_dir(tmp_path)
+	(repo_dir / "actions.toml").write_text("quality = 1\n", encoding="utf-8")
+	page = ActionsPage()
+	page.on_enter(ctx)
+	assert "unexpected key" in page.status_text()
+	assert page.handle_key(key("Esc")).action == PageAction.POP
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
index 7863382..ac1ed0d 100644
--- a/pygittools/tui/pages/home.py
+++ b/pygittools/tui/pages/home.py
@@ -3,10 +3,12 @@
 from __future__ import annotations
 
 import time
+from pathlib import Path
 from typing import Literal
 
 from pyratatui import Constraint, Direction, Frame, Layout, Paragraph, Rect
 
+from pygittools.actions import find_project_root, load_action_sets
 from pygittools.tui.branches import current_branch_name
 from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.commit_draft import load_commit_draft
@@ -21,7 +23,7 @@ from pygittools.tui.types import Page, PageAction, PageContext, PageResult
 from pygittools.tui.worktrees_list import WorktreesList
 
 _TABS: tuple[str, ...] = ("Status", "Log", "Tasks", "Worktrees")
-_HeaderFocus = Literal["changes", "repo", "branch"]
+_HeaderFocus = Literal["changes", "repo", "branch", "actions"]
 _HomeTab = Literal["status", "log", "tasks", "worktrees"]
 _TAB_KEYS: tuple[_HomeTab, ...] = ("status", "log", "tasks", "worktrees")
 _IDLE_REFRESH_S = 10.0
@@ -39,6 +41,7 @@ class HomePage:
 		self._active_tab: _HomeTab = "status"
 		self._header_focus: _HeaderFocus = "changes"
 		self._input_mode: InputMode = nav_context()
+		self._action_sets: tuple[str, ...] = ()
 		self._last_refresh_at = 0.0
 
 	@property
@@ -72,10 +75,17 @@ class HomePage:
 	def active_tab(self) -> _HomeTab:
 		return self._active_tab
 
+	@property
+	def has_actions(self) -> bool:
+		return bool(self._action_sets)
+
 	def set_active_tab(self, tab: _HomeTab) -> None:
 		self._active_tab = tab
-		if tab != "status" and isinstance(self._input_mode, InputContext):
-			self._input_mode = nav_context()
+		if tab != "status":
+			if isinstance(self._input_mode, InputContext):
+				self._input_mode = nav_context()
+			if self._header_focus == "actions":
+				self._header_focus = "changes"
 
 	def _refresh(self) -> None:
 		ctx = self._ctx
@@ -85,6 +95,7 @@ class HomePage:
 		self._log.refresh(ctx.repo)
 		self._tasks.refresh(ctx.repo)
 		self._worktrees.refresh(ctx.repo)
+		self._action_sets = _action_set_names(ctx)
 		self._last_refresh_at = time.monotonic()
 
 	def on_enter(self, ctx: PageContext) -> None:
@@ -110,16 +121,12 @@ class HomePage:
 		project_label = repo_project_label(ctx.repo_path)
 		branch = current_branch_name(ctx.repo)
 
-		chunks = (
-			Layout()
-			.direction(Direction.Vertical)
-			.constraints([
-				Constraint.length(1),
-				Constraint.length(1),
-				Constraint.fill(1),
-			])
-			.split(area)
-		)
+		show_actions = self._active_tab == "status"
+		constraints = [Constraint.length(1), Constraint.length(1)]
+		if show_actions:
+			constraints.append(Constraint.length(1))
+		constraints.append(Constraint.fill(1))
+		chunks = Layout().direction(Direction.Vertical).constraints(constraints).split(area)
 
 		try:
 			active_index = _TAB_KEYS.index(self._active_tab)
@@ -145,7 +152,17 @@ class HomePage:
 			branch_label = f"{branch_label} — [Add worktree]"
 		frame.render_widget(Paragraph.from_string(branch_label).style(branch_style), chunks[1])
 
-		content = chunks[2]
+		content = chunks[-1]
+		if show_actions:
+			if self._action_sets:
+				actions_label = f"Actions: {', '.join(self._action_sets)}"
+				actions_style = theme.header
+			else:
+				actions_label = "No Action Groups"
+				actions_style = theme.log_date.dim()
+			if self._header_focus == "actions":
+				actions_style = focus_style(actions_style)
+			frame.render_widget(Paragraph.from_string(actions_label).style(actions_style), chunks[2])
 		content_highlight = self._header_focus == "changes"
 		commit_input = isinstance(self._input_mode, InputContext)
 		if self._active_tab == "status":
@@ -179,3 +196,16 @@ class HomePage:
 
 def home_page() -> Page:
 	return HomePage()
+
+
+def _action_set_names(ctx: PageContext) -> tuple[str, ...]:
+	workdir = Path(ctx.repo.workdir) if ctx.repo.workdir else Path(ctx.repo_path)
+	if workdir.name == ".git":
+		workdir = workdir.parent
+	root = find_project_root(workdir)
+	if root is None:
+		return ()
+	try:
+		return tuple(action_set.name for action_set in load_action_sets(root))
+	except (OSError, ValueError):
+		return ()
diff --git a/pygittools/tui/pages_test.py b/pygittools/tui/pages_test.py
index 43c53ab..d4b4ec5 100644
--- a/pygittools/tui/pages_test.py
+++ b/pygittools/tui/pages_test.py
@@ -149,6 +149,29 @@ def test_home_page_push_help(page_ctx: PageContext) -> None:
 	assert isinstance(result.next_page, HelpPage)
 
 
+def test_home_page_actions_row(repo: Repository) -> None:
+	workdir = Path(repo.workdir or repo.path)
+	(workdir / "pyproject.toml").write_text(
+		'[tool.pgt]\nactions = [{ name = "lint", script = "lint.py" }]\n\n'
+		'[tool.pgt.sets.release]\nactions = [{ name = "build", script = "build.py" }]\n',
+		encoding="utf-8",
+	)
+	ctx = PageContext(repo=repo, repo_path=Path(repo.path))
+	page = home_page()
+	assert isinstance(page, HomePage)
+	page.on_enter(ctx)
+	assert page.has_actions
+	page.handle_key(key("Up"))
+	assert page.header_focus == "actions"
+	assert "open actions" in page.status_text()
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	opened = page.handle_key(key("Enter"))
+	assert opened.action == PageAction.PUSH
+	assert isinstance(opened.next_page, ActionsPage)
+	page.set_active_tab("log")
+	assert page.header_focus == "changes"
+
+
 def test_home_page_push_actions(page_ctx: PageContext) -> None:
 	page = home_page()
 	page.on_enter(page_ctx)
@@ -186,6 +209,7 @@ def test_empty_repo_can_create_branch_and_commit(repo: Repository) -> None:
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
 	page.handle_key(key("Up"))
+	page.handle_key(key("Up"))
 	opened = page.handle_key(key("Enter"))
 	assert opened.action == PageAction.PUSH
 	assert isinstance(opened.next_page, BranchPickerPage)
diff --git a/pyproject.toml b/pyproject.toml
index d360282..023f40d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,3 +72,11 @@ actions = [
 	{ name = "mypy", script = "scripts/mypy.py" },
 	{ name = "pytest", script = "scripts/pytest.py" },
 ]
+
+[tool.pgt.sets.testversions]
+actions = [
+	{ name = "pytest 3.11", script = "scripts/pytest_version.py", args = ["3.11"] },
+	{ name = "pytest 3.12", script = "scripts/pytest_version.py", args = ["3.12"] },
+	{ name = "pytest 3.13", script = "scripts/pytest_version.py", args = ["3.13"] },
+	{ name = "pytest 3.14", script = "scripts/pytest_version.py", args = ["3.14"] },
+]
diff --git a/scripts/pytest_version.py b/scripts/pytest_version.py
new file mode 100644
index 0000000..fff1f0b
--- /dev/null
+++ b/scripts/pytest_version.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Run the default pytest suite on one Python version in a temporary venv."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+def run(version: str, pytest_args: list[str]) -> int:
+	root = Path(__file__).resolve().parents[1]
+	with tempfile.TemporaryDirectory(prefix=f"pygitweb-{version}-", ignore_cleanup_errors=True) as tmp:
+		env = os.environ.copy()
+		env.pop("VIRTUAL_ENV", None)
+		env.pop("PYTHONHOME", None)
+		env["UV_PROJECT_ENVIRONMENT"] = str(Path(tmp) / ".venv")
+		sync = subprocess.run(
+			["uv", "sync", "--all-packages", "--python", version],
+			cwd=root,
+			env=env,
+			check=False,
+		)
+		if sync.returncode != 0:
+			return sync.returncode
+		proc = subprocess.run(
+			["uv", "run", "--python", version, "pytest", ".", *pytest_args],
+			cwd=root,
+			env=env,
+			check=False,
+		)
+		return proc.returncode
+
+
+def main() -> int:
+	if len(sys.argv) < 2 or sys.argv[1] == "":
+		print("usage: pytest_version.py VERSION [pytest args...]", file=sys.stderr)
+		return 2
+	return run(sys.argv[1], sys.argv[2:])
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/scripts/pytest_version_test.py b/scripts/pytest_version_test.py
new file mode 100644
index 0000000..9b06597
--- /dev/null
+++ b/scripts/pytest_version_test.py
@@ -0,0 +1,82 @@
+"""Tests for the per-version pytest action script."""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+from types import ModuleType
+
+import pytest
+
+
+def _load_script() -> ModuleType:
+	path = Path(__file__).resolve().parent / "pytest_version.py"
+	spec = importlib.util.spec_from_file_location("pytest_version_script", path)
+	assert spec is not None and spec.loader is not None
+	module = importlib.util.module_from_spec(spec)
+	spec.loader.exec_module(module)
+	return module
+
+
+def test_run_syncs_temp_env_then_pytest(monkeypatch: pytest.MonkeyPatch) -> None:
+	module = _load_script()
+	commands: list[list[str]] = []
+	envs: list[dict[str, str]] = []
+
+	class _Proc:
+		def __init__(self, returncode: int) -> None:
+			self.returncode = returncode
+
+	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+		commands.append(list(cmd))
+		env = kwargs["env"]
+		assert isinstance(env, dict)
+		envs.append({str(key): str(value) for key, value in env.items()})
+		return _Proc(0)
+
+	monkeypatch.setattr(module.subprocess, "run", fake_run)
+	assert module.run("3.12", ["-k", "foo"]) == 0
+	assert commands[0] == ["uv", "sync", "--all-packages", "--python", "3.12"]
+	assert commands[1] == ["uv", "run", "--python", "3.12", "pytest", ".", "-k", "foo"]
+	assert "VIRTUAL_ENV" not in envs[0]
+	assert envs[0]["UV_PROJECT_ENVIRONMENT"].endswith(".venv")
+	assert envs[0]["UV_PROJECT_ENVIRONMENT"] == envs[1]["UV_PROJECT_ENVIRONMENT"]
+
+
+def test_run_stops_when_sync_fails(monkeypatch: pytest.MonkeyPatch) -> None:
+	module = _load_script()
+	commands: list[list[str]] = []
+
+	class _Proc:
+		returncode = 4
+
+	def fake_run(cmd: list[str], **kwargs: object) -> _Proc:
+		del kwargs
+		commands.append(list(cmd))
+		return _Proc()
+
+	monkeypatch.setattr(module.subprocess, "run", fake_run)
+	assert module.run("3.11", []) == 4
+	assert len(commands) == 1
+
+
+def test_main_requires_version(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
+	module = _load_script()
+	monkeypatch.setattr(sys, "argv", ["pytest_version.py"])
+	assert module.main() == 2
+	assert "usage:" in capsys.readouterr().err
+
+
+def test_main_forwards_pytest_args(monkeypatch: pytest.MonkeyPatch) -> None:
+	module = _load_script()
+	seen: list[tuple[str, list[str]]] = []
+
+	def fake_run(version: str, pytest_args: list[str]) -> int:
+		seen.append((version, pytest_args))
+		return 0
+
+	monkeypatch.setattr(module, "run", fake_run)
+	monkeypatch.setattr(sys, "argv", ["pytest_version.py", "3.14", "-q"])
+	assert module.main() == 0
+	assert seen == [("3.14", ["-q"])]
