diff --git a/pygittools/hook_samples/commit-msg.pattern b/pygittools/hook_samples/commit-msg.pattern
index 0380472..c95c287 100644
--- a/pygittools/hook_samples/commit-msg.pattern
+++ b/pygittools/hook_samples/commit-msg.pattern
@@ -1,6 +1,9 @@
 #!/usr/bin/env -S uv run python
 
 from __future__ import annotations
+
+__VERSION__ = "1"
+
 import sys
 import pygit2
 from pygittools.hooks import HookResult
diff --git a/pygittools/hook_samples/post-commit.notify b/pygittools/hook_samples/post-commit.notify
index ff97780..4de96ea 100644
--- a/pygittools/hook_samples/post-commit.notify
+++ b/pygittools/hook_samples/post-commit.notify
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+__VERSION__ = "1"
+
 import os
 
 import pygit2
diff --git a/pygittools/hook_samples/post-receive.notify b/pygittools/hook_samples/post-receive.notify
index dc196ce..f869342 100644
--- a/pygittools/hook_samples/post-receive.notify
+++ b/pygittools/hook_samples/post-receive.notify
@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+__VERSION__ = "1"
+
 import os
 import sys
 
diff --git a/pygittools/hook_samples/pre-commit.ruff b/pygittools/hook_samples/pre-commit.ruff
index 033f70f..589cf1c 100644
--- a/pygittools/hook_samples/pre-commit.ruff
+++ b/pygittools/hook_samples/pre-commit.ruff
@@ -1,6 +1,9 @@
 #!/usr/bin/env -S uv run python
 
 from __future__ import annotations
+
+__VERSION__ = "1"
+
 import pygit2
 from pygittools.hooks_ruff import PreCommitRuff
 
diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 7ee8713..faf3cb3 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -40,7 +40,7 @@ from pygitweb.git_helpers import (
 	parse_commit,
 	parse_tag,
 )
-from pygitweb.hooks_install import HookStatus, bundle_status
+from pygitweb.hooks_install import list_installable_hooks, list_installed_hooks
 from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
 from pygitweb.projects import git_get_project_owner
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
@@ -518,40 +518,19 @@ def git_search_page(project: str) -> HTMLResponse:
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
-UPDATE_HOOK_BUNDLE: str = "update"
-
-
-def _update_hook_cell(project: str, project_enc: str) -> str:
-	"""Render the Update Hook row's value: status text + toggle button driven by hook-install.js.
-
-	The "update" bundle wires both server-side (post-receive) and client-side (post-commit) notify
-	hooks so long-poll subscribers wake on either pushes or local commits in working clones.
-	"""
-	tpl = env.get_template("update_hook_cell.html")
+def _hooks_summary_cell(project: str, project_enc: str) -> str:
+	"""Render the Hooks row: installed samples with versions and an install dropdown."""
+	tpl = env.get_template("hooks_summary_cell.html")
 	try:
-		current: HookStatus = bundle_status(project, UPDATE_HOOK_BUNDLE)
+		installed = list_installed_hooks(project)
+		available = list_installable_hooks(project)
 	except (KeyError, OSError):
 		return tpl.render(mode="unavailable")
-	if current == HookStatus.DIFFERENT:
-		return tpl.render(mode="different")
-	if current == HookStatus.INSTALLED:
-		return tpl.render(
-			mode="toggle",
-			bundle=UPDATE_HOOK_BUNDLE,
-			project_enc=project_enc,
-			state_label="installed",
-			button_label="Remove Update Hook",
-			next_op="remove",
-			button_class="btn btn-sm btn-ghost-secondary hook-toggle",
-		)
 	return tpl.render(
-		mode="toggle",
-		bundle=UPDATE_HOOK_BUNDLE,
+		mode="manage",
 		project_enc=project_enc,
-		state_label="not installed",
-		button_label="Install Update Hook",
-		next_op="add",
-		button_class="btn btn-sm btn-primary hook-toggle",
+		installed=installed,
+		available=available,
 	)
 
 
@@ -618,7 +597,7 @@ def git_summary(project: str, extra_rows: list[list[str]] | None = None) -> HTML
 		["Remotes", f"<a href='/project/{project}?a=remotes'>view remotes</a>"],
 		["Board", board_value],
 		["Search", f"<a href='/project/{project_enc}/search'>search files</a>"],
-		["Update Hook", _update_hook_cell(project, project_enc)],
+		["Hooks", _hooks_summary_cell(project, project_enc)],
 	]
 	if extra_rows:
 		rows.extend(extra_rows)
diff --git a/pygitweb/hooks_install.py b/pygitweb/hooks_install.py
index 2c2658e..9130c88 100644
--- a/pygitweb/hooks_install.py
+++ b/pygitweb/hooks_install.py
@@ -10,6 +10,7 @@ the repo (`<repo>/.git/hooks` for non-bare, `<repo>/hooks` for bare).
 from __future__ import annotations
 
 import os
+import re
 import shutil
 import stat
 from enum import StrEnum
@@ -23,6 +24,8 @@ from pygitweb.config import settings
 
 SAMPLES_DIR: Path = Path(pygittools.__file__).parent / "hook_samples"
 
+_VERSION_RE: re.Pattern[str] = re.compile(r"""^__VERSION__\s*=\s*(['"])(.*?)\1""", re.MULTILINE)
+
 _SAMPLE_LABELS: dict[str, str] = {
 	"post-commit.notify": "Post-commit Notify",
 	"post-receive.notify": "Post-receive Notify",
@@ -62,6 +65,69 @@ class HookBundle(TypedDict):
 	members: list[HookSample]
 
 
+class InstalledHookInfo(TypedDict):
+	name: str
+	label: str
+	target: str
+	status: str
+	version: str | None
+
+
+def read_hook_version(path: Path) -> str | None:
+	"""Return the __VERSION__ string from a hook file, if present."""
+	try:
+		text: str = path.read_text(encoding="utf-8")
+	except OSError:
+		return None
+	match: re.Match[str] | None = _VERSION_RE.search(text)
+	if match is None:
+		return None
+	return match.group(2)
+
+
+def get_installed_version(project: str, sample_name: str) -> str | None:
+	sample: HookSample | None = get_sample(sample_name)
+	if sample is None:
+		raise KeyError(f"Unknown hook sample: {sample_name}")
+	target: Path = _target_path(project, sample)
+	if target.is_file():
+		version: str | None = read_hook_version(target)
+		if version is not None:
+			return version
+	return read_hook_version(Path(sample["source"]))
+
+
+def list_installed_hooks(project: str) -> list[InstalledHookInfo]:
+	"""Return pygittools hook samples that are installed or conflict with a custom hook."""
+	installed: list[InstalledHookInfo] = []
+	for sample in list_samples():
+		st: HookStatus = status(project, sample["name"])
+		if st == HookStatus.NOT_INSTALLED:
+			continue
+		version: str | None = None
+		if st == HookStatus.INSTALLED:
+			version = get_installed_version(project, sample["name"])
+		else:
+			version = read_hook_version(_target_path(project, sample))
+		installed.append({
+			"name": sample["name"],
+			"label": sample["label"],
+			"target": sample["target"],
+			"status": st.value,
+			"version": version,
+		})
+	return installed
+
+
+def list_installable_hooks(project: str) -> list[HookSample]:
+	"""Return pygittools hook samples that can still be installed in this project."""
+	installable: list[HookSample] = []
+	for sample in list_samples():
+		if status(project, sample["name"]) == HookStatus.NOT_INSTALLED:
+			installable.append(sample)
+	return installable
+
+
 def list_samples() -> list[HookSample]:
 	if not SAMPLES_DIR.is_dir():
 		return []
diff --git a/pygitweb/hooks_install_test.py b/pygitweb/hooks_install_test.py
index 985d85b..2c7ec00 100644
--- a/pygitweb/hooks_install_test.py
+++ b/pygitweb/hooks_install_test.py
@@ -14,12 +14,16 @@ from pygitweb.hooks_install import (
 	HookStatus,
 	bundle_status,
 	get_bundle,
+	get_installed_version,
 	get_sample,
 	install,
 	install_bundle,
 	is_installed,
 	list_bundles,
+	list_installable_hooks,
+	list_installed_hooks,
 	list_samples,
+	read_hook_version,
 	remove,
 	remove_bundle,
 	status,
@@ -284,28 +288,64 @@ class TestHookRoute:
 		bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
 		assert "update" in bundle_names
 
-	def test_summary_renders_install_button_when_bundle_not_installed(self, project_env: dict[str, str]) -> None:
+	def test_summary_renders_install_dropdown_when_no_hooks_installed(self, project_env: dict[str, str]) -> None:
 		with _client() as client:
 			resp = client.get(f"/project/{project_env['project']}")
 		assert resp.status_code == 200
-		assert "Install Update Hook" in resp.text
-		assert 'data-sample="update"' in resp.text
-		assert "btn btn-sm btn-primary" in resp.text
+		assert 'id="hook-install-select"' in resp.text
+		assert "hook-install-btn" in resp.text
+		assert "Post-receive Notify" in resp.text
+		assert "No hooks installed." in resp.text
 
-	def test_summary_renders_remove_button_when_bundle_fully_installed(self, project_env: dict[str, str]) -> None:
-		install_bundle(project_env["project"], "update")
+	def test_summary_renders_uninstall_buttons_for_installed_hooks(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-receive.notify")
+		install(project_env["project"], "pre-commit.ruff")
 		with _client() as client:
 			resp = client.get(f"/project/{project_env['project']}")
 		assert resp.status_code == 200
-		assert "Remove Update Hook" in resp.text
-		assert "btn btn-sm btn-ghost-secondary" in resp.text
+		assert resp.text.count("Uninstall") == 2
+		assert 'data-sample="post-receive.notify"' in resp.text
+		assert 'data-sample="pre-commit.ruff"' in resp.text
+		assert ">v1<" in resp.text or "v1" in resp.text
 
-	def test_summary_renders_install_button_when_only_one_member_installed(self, project_env: dict[str, str]) -> None:
+	def test_summary_hides_installed_hooks_from_install_dropdown(self, project_env: dict[str, str]) -> None:
 		install(project_env["project"], "post-commit.notify")
 		with _client() as client:
 			resp = client.get(f"/project/{project_env['project']}")
 		assert resp.status_code == 200
-		assert "Install Update Hook" in resp.text
+		assert 'data-sample="post-commit.notify"' in resp.text
+		assert 'value="post-commit.notify"' not in resp.text
+
+	def test_summary_shows_custom_hook_conflict(self, project_env: dict[str, str]) -> None:
+		hooks_dir: Path = Path(project_env["hooks_dir"])
+		hooks_dir.mkdir(parents=True, exist_ok=True)
+		(hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
+		with _client() as client:
+			resp = client.get(f"/project/{project_env['project']}")
+		assert resp.status_code == 200
+		assert "custom hook installed; refusing to manage" in resp.text
+
+
+class TestHookVersion:
+	def test_read_hook_version_from_sample(self) -> None:
+		sample = get_sample("post-receive.notify")
+		assert sample is not None
+		assert read_hook_version(Path(sample["source"])) == "1"
+
+	def test_get_installed_version_reads_installed_file(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-receive.notify")
+		assert get_installed_version(project_env["project"], "post-receive.notify") == "1"
+
+	def test_list_installed_hooks_includes_only_present_hooks(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-commit.notify")
+		installed = list_installed_hooks(project_env["project"])
+		assert {entry["name"] for entry in installed} == {"post-commit.notify"}
+		assert installed[0]["version"] == "1"
+
+	def test_list_installable_hooks_excludes_installed(self, project_env: dict[str, str]) -> None:
+		install(project_env["project"], "post-commit.notify")
+		installable = list_installable_hooks(project_env["project"])
+		assert all(entry["name"] != "post-commit.notify" for entry in installable)
 
 
 class TestSummaryRefSwitcherStaticScript:
diff --git a/pygitweb/static/hook-install.js b/pygitweb/static/hook-install.js
index 1091aff..034c582 100644
--- a/pygitweb/static/hook-install.js
+++ b/pygitweb/static/hook-install.js
@@ -1,16 +1,5 @@
 (() => {
-	const handleClick = async (event) => {
-		const btn = event.target.closest(".hook-toggle");
-		if (!btn) {
-			return;
-		}
-		event.preventDefault();
-		const project = btn.dataset.project;
-		const sample = btn.dataset.sample;
-		const op = btn.dataset.op;
-		if (!project || !sample || !op) {
-			return;
-		}
+	const postHookOp = async (project, sample, op, btn) => {
 		btn.disabled = true;
 		const params = new URLSearchParams({ name: sample, op });
 		try {
@@ -31,5 +20,34 @@
 		}
 	};
 
+	const handleClick = async (event) => {
+		const uninstallBtn = event.target.closest(".hook-toggle");
+		if (uninstallBtn) {
+			event.preventDefault();
+			const project = uninstallBtn.dataset.project;
+			const sample = uninstallBtn.dataset.sample;
+			const op = uninstallBtn.dataset.op;
+			if (!project || !sample || !op) {
+				return;
+			}
+			await postHookOp(project, sample, op, uninstallBtn);
+			return;
+		}
+
+		const installBtn = event.target.closest(".hook-install-btn");
+		if (!installBtn) {
+			return;
+		}
+		event.preventDefault();
+		const project = installBtn.dataset.project;
+		const op = installBtn.dataset.op;
+		const select = document.getElementById("hook-install-select");
+		const sample = select instanceof HTMLSelectElement ? select.value : "";
+		if (!project || !sample || !op) {
+			return;
+		}
+		await postHookOp(project, sample, op, installBtn);
+	};
+
 	document.addEventListener("click", handleClick);
 })();
diff --git a/pygitweb/templates/hooks_summary_cell.html b/pygitweb/templates/hooks_summary_cell.html
new file mode 100644
index 0000000..c53c448
--- /dev/null
+++ b/pygitweb/templates/hooks_summary_cell.html
@@ -0,0 +1,37 @@
+{# Project summary hooks row: installed hooks + install dropdown (hook-install.js). #}
+{% if mode == "unavailable" %}
+<span class="text-muted">unavailable</span>
+{% else %}
+{% if installed %}
+<ul class="list-unstyled mb-2">
+  {% for hook in installed %}
+  <li class="d-flex flex-wrap align-items-center gap-2 mb-1">
+    <span>{{ hook.label }} (<code>{{ hook.target }}</code>)</span>
+    {% if hook.version %}
+    <span class="text-muted small">v{{ hook.version }}</span>
+    {% endif %}
+    {% if hook.status == "installed" %}
+    <button type="button" class="btn btn-sm btn-ghost-secondary hook-toggle" data-project="{{ project_enc }}" data-sample="{{ hook.name }}" data-op="remove">Uninstall</button>
+    {% elif hook.status == "different" %}
+    <span class="text-muted small">custom hook installed; refusing to manage</span>
+    {% endif %}
+  </li>
+  {% endfor %}
+</ul>
+{% else %}
+<p class="text-muted mb-2">No hooks installed.</p>
+{% endif %}
+{% if available %}
+<div class="d-flex flex-wrap align-items-center gap-2">
+  <label for="hook-install-select" class="visually-hidden">Install hook</label>
+  <select id="hook-install-select" class="form-select form-select-sm" style="width: auto; min-width: 12rem;">
+    {% for hook in available %}
+    <option value="{{ hook.name }}">{{ hook.label }} ({{ hook.target }})</option>
+    {% endfor %}
+  </select>
+  <button type="button" class="btn btn-sm btn-primary hook-install-btn" data-project="{{ project_enc }}" data-op="add">Install</button>
+</div>
+{% else %}
+<span class="text-muted">All available hooks are installed.</span>
+{% endif %}
+{% endif %}
diff --git a/pygitweb/templates/update_hook_cell.html b/pygitweb/templates/update_hook_cell.html
deleted file mode 100644
index 666dc82..0000000
--- a/pygitweb/templates/update_hook_cell.html
+++ /dev/null
@@ -1,9 +0,0 @@
-{# Update Hook summary cell: status + install/remove toggle (hook-install.js). Expects mode: unavailable | different | toggle. #}
-{% if mode == "unavailable" %}
-<span class="text-muted">unavailable</span>
-{% elif mode == "different" %}
-<span class="text-muted">A custom hook is already installed at one of <code>post-commit</code> / <code>post-receive</code>; refusing to manage it.</span>
-{% elif mode == "toggle" %}
-<span class="hook-state text-muted me-2" data-sample="{{ bundle }}">{{ state_label }}</span>
-<button type="button" class="{{ button_class }}" data-project="{{ project_enc }}" data-sample="{{ bundle }}" data-op="{{ next_op }}">{{ button_label }}</button>
-{% endif %}
