diff --git a/pygittools/tui/conftest.py b/pygittools/tui/conftest.py
index 86b3629..15fc88f 100644
--- a/pygittools/tui/conftest.py
+++ b/pygittools/tui/conftest.py
@@ -14,6 +14,14 @@ from pygittools.tui.types import PageContext
 SIG = Signature("tester", "tester@example.com", 0)
 
 
+@pytest.fixture(autouse=True)
+def _mock_worktree_list(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setattr(
+		"pygittools.tui.worktrees_list.list_worktrees",
+		lambda _repo: ([], None),
+	)
+
+
 @pytest.fixture
 def repo(tmp_path: Path) -> Repository:
 	repo_path = tmp_path / "repo"
diff --git a/pygittools/tui/coverage_gaps_test.py b/pygittools/tui/coverage_gaps_test.py
index 8344c16..45c45f1 100644
--- a/pygittools/tui/coverage_gaps_test.py
+++ b/pygittools/tui/coverage_gaps_test.py
@@ -25,6 +25,7 @@ from pygittools.tui.status_bar import _format_line
 from pygittools.tui.tasks_list import TasksBoardList
 from pygittools.tui.types import PageAction, PageContext, PageResult
 from pygittools.tui.worktree import commit_staged, unstage_paths
+from pygittools.tui.worktrees_list import WorktreesList
 
 
 def test_read_draft_skips_blank_lines(tmp_path: Path) -> None:
@@ -137,6 +138,7 @@ def test_input_context_log_tab_at_top(page_ctx: PageContext) -> None:
 		changes=ChangesList(),
 		log=CommitLogList(),
 		tasks=TasksBoardList(),
+		worktrees=WorktreesList(),
 	)
 	host.active_tab = "log"
 	host.log._list_state.select(0)
@@ -150,6 +152,7 @@ def test_input_context_input_without_relinquish(page_ctx: PageContext) -> None:
 		changes=ChangesList(),
 		log=CommitLogList(),
 		tasks=TasksBoardList(),
+		worktrees=WorktreesList(),
 	)
 	dispatch = InputContext().dispatch_key(host, key("x"))
 	assert dispatch.next_mode is None
diff --git a/pygittools/tui/git_worktrees.py b/pygittools/tui/git_worktrees.py
new file mode 100644
index 0000000..49f8651
--- /dev/null
+++ b/pygittools/tui/git_worktrees.py
@@ -0,0 +1,156 @@
+"""Git worktree listing and mutation via the git CLI."""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+from pygit2 import Repository
+
+from pygittools.hooks_push import git_executable
+
+
+@dataclass(frozen=True, slots=True)
+class WorktreeInfo:
+	path: Path
+	head: str | None
+	branch: str | None
+	bare: bool
+	detached: bool
+	locked: str | None
+	prunable: str | None
+
+
+def list_worktrees(repo: Repository) -> tuple[list[WorktreeInfo], str | None]:
+	workdir = repo.workdir
+	if workdir is None:
+		return [], "Repository has no working directory"
+	result = subprocess.run(
+		[git_executable(), "worktree", "list", "--porcelain"],
+		cwd=workdir,
+		capture_output=True,
+		text=True,
+		check=False,
+	)
+	if result.returncode != 0:
+		output = (result.stderr or result.stdout or "git worktree list failed").strip()
+		return [], output.splitlines()[-1]
+	return parse_worktree_porcelain(result.stdout), None
+
+
+def parse_worktree_porcelain(text: str) -> list[WorktreeInfo]:
+	entries: list[WorktreeInfo] = []
+	block: list[str] = []
+	for raw_line in text.splitlines():
+		line = raw_line.strip()
+		if not line:
+			if block:
+				entries.append(_block_to_info(block))
+				block = []
+			continue
+		if line.startswith("worktree "):
+			if block:
+				entries.append(_block_to_info(block))
+			block = [line]
+		else:
+			block.append(line)
+	if block:
+		entries.append(_block_to_info(block))
+	return entries
+
+
+def _block_to_info(lines: list[str]) -> WorktreeInfo:
+	path = Path(lines[0].removeprefix("worktree ").strip())
+	head: str | None = None
+	branch: str | None = None
+	bare = False
+	detached = False
+	locked: str | None = None
+	prunable: str | None = None
+	for line in lines[1:]:
+		if line == "bare":
+			bare = True
+		elif line == "detached":
+			detached = True
+		elif line == "locked":
+			locked = ""
+		elif line.startswith("locked "):
+			locked = line.removeprefix("locked ").strip()
+		elif line == "prunable":
+			prunable = ""
+		elif line.startswith("prunable "):
+			prunable = line.removeprefix("prunable ").strip()
+		elif line.startswith("HEAD "):
+			head = line.removeprefix("HEAD ").strip()
+		elif line.startswith("branch "):
+			ref = line.removeprefix("branch ").strip()
+			branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
+	return WorktreeInfo(
+		path=path,
+		head=head,
+		branch=branch,
+		bare=bare,
+		detached=detached,
+		locked=locked,
+		prunable=prunable,
+	)
+
+
+def add_worktree(repo: Repository, path: Path, branch: str | None) -> tuple[bool, str]:
+	workdir = repo.workdir
+	if workdir is None:
+		return False, "Repository has no working directory"
+	trimmed_branch = (branch or "").strip()
+	target = _resolve_worktree_path(workdir, path)
+	cmd = [git_executable(), "worktree", "add"]
+	if trimmed_branch:
+		local = {name for name in repo.branches.local}
+		if trimmed_branch in local:
+			cmd.extend([str(target), trimmed_branch])
+		else:
+			cmd.extend(["-b", trimmed_branch, str(target)])
+	else:
+		cmd.append(str(target))
+	result = subprocess.run(
+		cmd,
+		cwd=workdir,
+		capture_output=True,
+		text=True,
+		check=False,
+	)
+	if result.returncode == 0:
+		return True, ""
+	output = (result.stderr or result.stdout or "git worktree add failed").strip()
+	if not output:
+		return False, "git worktree add failed"
+	return False, output.splitlines()[-1]
+
+
+def remove_worktree(repo: Repository, path: Path, *, force: bool) -> tuple[bool, str]:
+	workdir = repo.workdir
+	if workdir is None:
+		return False, "Repository has no working directory"
+	cmd = [git_executable(), "worktree", "remove"]
+	if force:
+		cmd.append("--force")
+	cmd.append(str(path.resolve()))
+	result = subprocess.run(
+		cmd,
+		cwd=workdir,
+		capture_output=True,
+		text=True,
+		check=False,
+	)
+	if result.returncode == 0:
+		return True, ""
+	output = (result.stderr or result.stdout or "git worktree remove failed").strip()
+	if not output:
+		return False, "git worktree remove failed"
+	return False, output.splitlines()[-1]
+
+
+def _resolve_worktree_path(workdir: str | Path, path: Path) -> Path:
+	if path.is_absolute():
+		return path
+	return (Path(workdir) / path).resolve()
diff --git a/pygittools/tui/git_worktrees_test.py b/pygittools/tui/git_worktrees_test.py
new file mode 100644
index 0000000..3938259
--- /dev/null
+++ b/pygittools/tui/git_worktrees_test.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+from pygit2 import Repository, init_repository
+
+from pygittools.tui.git_worktrees import (
+	add_worktree,
+	list_worktrees,
+	parse_worktree_porcelain,
+	remove_worktree,
+)
+
+
+def test_parse_worktree_porcelain() -> None:
+	text = """
+worktree /repo/main
+HEAD abcdef1234567890
+branch refs/heads/main
+
+worktree /repo/feature
+HEAD 1111111111111111
+branch refs/heads/feature
+locked
+
+worktree /repo/old
+prunable gitdir file points to non-existent location
+
+""".strip()
+	entries = parse_worktree_porcelain(text)
+	assert len(entries) == 3
+	assert entries[0].path == Path("/repo/main")
+	assert entries[0].branch == "main"
+	assert entries[0].head == "abcdef1234567890"
+	assert entries[1].locked == ""
+	assert entries[1].branch == "feature"
+	assert entries[2].prunable == "gitdir file points to non-existent location"
+
+
+def test_parse_detached_and_bare() -> None:
+	text = """
+worktree /repo/detached
+HEAD deadbeef
+detached
+
+worktree /repo/bare.git
+bare
+
+""".strip()
+	entries = parse_worktree_porcelain(text)
+	assert entries[0].detached is True
+	assert entries[0].branch is None
+	assert entries[1].bare is True
+
+
+def test_list_worktrees_no_workdir(monkeypatch: pytest.MonkeyPatch) -> None:
+	repo = MagicMock(spec=Repository)
+	repo.workdir = None
+	entries, error = list_worktrees(repo)
+	assert entries == []
+	assert error is not None
+
+
+def test_add_worktree_requires_path(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	calls: list[list[str]] = []
+
+	def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
+		calls.append(cmd)
+		return MagicMock(returncode=0, stdout="", stderr="")
+
+	monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
+	ok, error = add_worktree(repo, Path("../wt"), "feature")
+	assert ok is True
+	assert error == ""
+	assert calls
+	assert "-b" in calls[0] or "feature" in calls[0]
+
+
+def test_remove_worktree_force(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	calls: list[list[str]] = []
+
+	def fake_run(cmd: list[str], **kwargs: object) -> MagicMock:
+		calls.append(cmd)
+		return MagicMock(returncode=0, stdout="", stderr="")
+
+	monkeypatch.setattr("pygittools.tui.git_worktrees.subprocess.run", fake_run)
+	ok, error = remove_worktree(repo, Path("/tmp/wt"), force=True)
+	assert ok is True
+	assert "--force" in calls[0]
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> Repository:
+	repo_path = tmp_path / "repo"
+	repo_path.mkdir()
+	return init_repository(str(repo_path), bare=False)
diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
index 8116d5e..009f952 100644
--- a/pygittools/tui/input_context.py
+++ b/pygittools/tui/input_context.py
@@ -11,13 +11,15 @@ from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.branches import branch_picker_page
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.pages.projects import projects_page
+from pygittools.tui.pages.worktree_add import worktree_add_page
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 if TYPE_CHECKING:
 	from pygittools.tui.tasks_list import TasksBoardList
+	from pygittools.tui.worktrees_list import WorktreesList
 
 _HeaderFocus = Literal["changes", "repo", "branch"]
-_HomeTab = Literal["status", "log", "tasks"]
+_HomeTab = Literal["status", "log", "tasks", "worktrees"]
 
 
 @dataclass(frozen=True, slots=True)
@@ -49,6 +51,9 @@ class PageInputHost(Protocol):
 	@property
 	def tasks(self) -> TasksBoardList: ...
 
+	@property
+	def worktrees(self) -> WorktreesList: ...
+
 
 class NavContext:
 	def status_hint(self, host: PageInputHost) -> str:
@@ -57,11 +62,15 @@ class NavContext:
 		if host.header_focus == "branch":
 			if host.active_tab == "tasks":
 				return "Enter sync tasks — j/k move — H/L switch tab"
+			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.active_tab == "log":
 			return host.log.status_hint()
 		if host.active_tab == "tasks":
 			return host.tasks.status_hint()
+		if host.active_tab == "worktrees":
+			return host.worktrees.status_hint()
 		return host.changes.status_hint()
 
 	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
@@ -87,6 +96,10 @@ class NavContext:
 				if host.active_tab == "tasks" and host.page_context is not None:
 					host.tasks.sync_with_default_remote(host.page_context.repo)
 					return ContextDispatch()
+				if host.active_tab == "worktrees":
+					return ContextDispatch(
+						page_result=PageResult(action=PageAction.PUSH, next_page=worktree_add_page()),
+					)
 				return ContextDispatch(
 					page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
 				)
@@ -105,6 +118,9 @@ class NavContext:
 		if host.active_tab == "tasks":
 			page_result = host.tasks.handle_nav_key(key, ctx.repo)
 			return ContextDispatch(page_result=page_result)
+		if host.active_tab == "worktrees":
+			page_result = host.worktrees.handle_nav_key(key, ctx.repo)
+			return ContextDispatch(page_result=page_result)
 		nav_result = host.changes.handle_nav_key(key, ctx.repo)
 		if nav_result.enter_input:
 			return ContextDispatch(next_mode="input")
@@ -143,11 +159,13 @@ def _content_list_is_at_top(host: PageInputHost) -> bool:
 		return host.log.is_at_top()
 	if host.active_tab == "tasks":
 		return host.tasks.is_at_top()
+	if host.active_tab == "worktrees":
+		return host.worktrees.is_at_top()
 	return host.changes.is_at_top()
 
 
 def _try_switch_tab(host: PageInputHost, key: KeyLike) -> bool:
-	tabs: tuple[_HomeTab, ...] = ("status", "log", "tasks")
+	tabs: tuple[_HomeTab, ...] = ("status", "log", "tasks", "worktrees")
 	current = host.active_tab
 	index = tabs.index(current)
 	if is_left(key):
diff --git a/pygittools/tui/input_context_test.py b/pygittools/tui/input_context_test.py
index f9b81fb..36af11f 100644
--- a/pygittools/tui/input_context_test.py
+++ b/pygittools/tui/input_context_test.py
@@ -14,9 +14,10 @@ from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.help import HelpPage
 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"]
-_HomeTab = Literal["status", "log", "tasks"]
+_HomeTab = Literal["status", "log", "tasks", "worktrees"]
 
 
 @dataclass
@@ -25,6 +26,7 @@ class FakeHost:
 	changes: ChangesList
 	log: CommitLogList
 	tasks: TasksBoardList
+	worktrees: WorktreesList
 	header_focus: _HeaderFocus = "changes"
 	active_tab: _HomeTab = "status"
 
@@ -43,7 +45,9 @@ def host(page_ctx: PageContext) -> FakeHost:
 	log.refresh(page_ctx.repo)
 	tasks = TasksBoardList()
 	tasks.refresh(page_ctx.repo)
-	return FakeHost(page_context=page_ctx, changes=changes, log=log, tasks=tasks)
+	worktrees = WorktreesList()
+	worktrees.refresh(page_ctx.repo)
+	return FakeHost(page_context=page_ctx, changes=changes, log=log, tasks=tasks, worktrees=worktrees)
 
 
 def test_nav_context_status_hints(host: FakeHost) -> None:
@@ -134,12 +138,18 @@ def test_input_context_commit_relinquishes(host: FakeHost, monkeypatch: pytest.M
 
 def test_input_context_status_hint() -> None:
 	assert "Esc" in InputContext().status_hint(
-		FakeHost(None, ChangesList(), CommitLogList(), TasksBoardList()),
+		FakeHost(None, ChangesList(), CommitLogList(), TasksBoardList(), WorktreesList()),
 	)
 
 
 def test_nav_context_without_page_context() -> None:
-	host = FakeHost(page_context=None, changes=ChangesList(), log=CommitLogList(), tasks=TasksBoardList())
+	host = FakeHost(
+		page_context=None,
+		changes=ChangesList(),
+		log=CommitLogList(),
+		tasks=TasksBoardList(),
+		worktrees=WorktreesList(),
+	)
 	assert nav_context().dispatch_key(host, key("Down")).page_result is None
 
 
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
index e2146d2..7863382 100644
--- a/pygittools/tui/pages/home.py
+++ b/pygittools/tui/pages/home.py
@@ -18,11 +18,12 @@ from pygittools.tui.project_label import repo_project_label
 from pygittools.tui.tabs import render_tab_row
 from pygittools.tui.tasks_list import TasksBoardList
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
+from pygittools.tui.worktrees_list import WorktreesList
 
-_TABS: tuple[str, ...] = ("Status", "Log", "Tasks")
+_TABS: tuple[str, ...] = ("Status", "Log", "Tasks", "Worktrees")
 _HeaderFocus = Literal["changes", "repo", "branch"]
-_HomeTab = Literal["status", "log", "tasks"]
-_TAB_KEYS: tuple[_HomeTab, ...] = ("status", "log", "tasks")
+_HomeTab = Literal["status", "log", "tasks", "worktrees"]
+_TAB_KEYS: tuple[_HomeTab, ...] = ("status", "log", "tasks", "worktrees")
 _IDLE_REFRESH_S = 10.0
 
 
@@ -34,6 +35,7 @@ class HomePage:
 		self._changes = ChangesList()
 		self._log = CommitLogList()
 		self._tasks = TasksBoardList()
+		self._worktrees = WorktreesList()
 		self._active_tab: _HomeTab = "status"
 		self._header_focus: _HeaderFocus = "changes"
 		self._input_mode: InputMode = nav_context()
@@ -63,6 +65,10 @@ class HomePage:
 		return self._tasks
 
 	@property
+	def worktrees(self) -> WorktreesList:
+		return self._worktrees
+
+	@property
 	def active_tab(self) -> _HomeTab:
 		return self._active_tab
 
@@ -78,6 +84,7 @@ class HomePage:
 		self._changes.refresh(ctx.repo)
 		self._log.refresh(ctx.repo)
 		self._tasks.refresh(ctx.repo)
+		self._worktrees.refresh(ctx.repo)
 		self._last_refresh_at = time.monotonic()
 
 	def on_enter(self, ctx: PageContext) -> None:
@@ -134,6 +141,8 @@ class HomePage:
 		branch_label = f"Branch: {branch}"
 		if self._active_tab == "tasks":
 			branch_label = f"{branch_label} — [Sync tasks]"
+		elif self._active_tab == "worktrees":
+			branch_label = f"{branch_label} — [Add worktree]"
 		frame.render_widget(Paragraph.from_string(branch_label).style(branch_style), chunks[1])
 
 		content = chunks[2]
@@ -148,8 +157,10 @@ class HomePage:
 			)
 		elif self._active_tab == "log":
 			self._log.draw(frame, content, highlight=content_highlight)
-		else:
+		elif self._active_tab == "tasks":
 			self._tasks.draw(frame, content, highlight=content_highlight)
+		else:
+			self._worktrees.draw(frame, content, highlight=content_highlight)
 
 	def handle_key(self, key: KeyLike) -> PageResult:
 		if is_quit(key) and not isinstance(self._input_mode, InputContext):
diff --git a/pygittools/tui/pages/worktree_add.py b/pygittools/tui/pages/worktree_add.py
new file mode 100644
index 0000000..b884dad
--- /dev/null
+++ b/pygittools/tui/pages/worktree_add.py
@@ -0,0 +1,120 @@
+"""Form to add a new git worktree."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from pyratatui import (
+	Constraint,
+	Direction,
+	Frame,
+	Layout,
+	Paragraph,
+	Rect,
+	TextPrompt,
+	TextState,
+)
+
+from pygittools.tui.git_colors import current_theme
+from pygittools.tui.git_worktrees import add_worktree
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+_Field = Literal["path", "branch"]
+
+
+class WorktreeAddPage:
+	title = "worktree-add"
+
+	def __init__(self) -> None:
+		self._ctx: PageContext | None = None
+		self._path_state = TextState()
+		self._branch_state = TextState()
+		self._field: _Field = "path"
+		self._error: str | None = None
+
+	def on_enter(self, ctx: PageContext) -> None:
+		self._ctx = ctx
+		self._path_state.set_value("")
+		self._branch_state.set_value("")
+		self._field = "path"
+		self._error = None
+		self._path_state.focus()
+		self._branch_state.blur()
+
+	def status_text(self) -> str:
+		if self._error:
+			return self._error
+		return "Enter submit — j/k switch field — Esc back"
+
+	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.length(1),
+				Constraint.length(1),
+				Constraint.fill(1),
+			])
+			.split(area)
+		)
+		frame.render_widget(Paragraph.from_string("Add worktree").style(theme.header), chunks[0])
+		self._sync_focus()
+		frame.render_text_prompt(TextPrompt("Path: "), chunks[1], self._path_state)
+		frame.render_text_prompt(
+			TextPrompt("Branch (new or existing, empty = detached): "),
+			chunks[2],
+			self._branch_state,
+		)
+		hint = "Path is relative to the current worktree unless absolute."
+		frame.render_widget(Paragraph.from_string(hint).style(theme.log_date), chunks[3])
+
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_esc(key) or is_quit(key):
+			return PageResult(action=PageAction.POP)
+		if self._error is not None:
+			self._error = None
+		ctx = self._ctx
+		if ctx is None:
+			return PageResult()
+		if is_up(key):
+			self._field = "path"
+			return PageResult()
+		if is_down(key):
+			self._field = "branch"
+			return PageResult()
+		if is_enter(key):
+			return self._submit(ctx)
+		state = self._path_state if self._field == "path" else self._branch_state
+		state.focus()
+		state.handle_key(key)
+		return PageResult()
+
+	def _sync_focus(self) -> None:
+		if self._field == "path":
+			self._path_state.focus()
+			self._branch_state.blur()
+		else:
+			self._branch_state.focus()
+			self._path_state.blur()
+
+	def _submit(self, ctx: PageContext) -> PageResult:
+		raw_path = self._path_state.value().strip()
+		if not raw_path:
+			self._error = "Path is required"
+			return PageResult()
+		branch = self._branch_state.value().strip() or None
+		ok, error = add_worktree(ctx.repo, Path(raw_path), branch)
+		if ok:
+			return PageResult(action=PageAction.POP)
+		self._error = error
+		return PageResult()
+
+
+def worktree_add_page() -> WorktreeAddPage:
+	return WorktreeAddPage()
diff --git a/pygittools/tui/pages_test.py b/pygittools/tui/pages_test.py
index 9c31364..65c0fd7 100644
--- a/pygittools/tui/pages_test.py
+++ b/pygittools/tui/pages_test.py
@@ -36,11 +36,13 @@ def test_home_page_draw_skips_refresh(page_ctx: PageContext) -> None:
 		patch.object(page.changes, "refresh") as changes_refresh,
 		patch.object(page.log, "refresh") as log_refresh,
 		patch.object(page.tasks, "refresh") as tasks_refresh,
+		patch.object(page.worktrees, "refresh") as worktrees_refresh,
 	):
 		page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	changes_refresh.assert_not_called()
 	log_refresh.assert_not_called()
 	tasks_refresh.assert_not_called()
+	worktrees_refresh.assert_not_called()
 
 
 def test_home_page_idle_refresh_after_interval(page_ctx: PageContext) -> None:
@@ -51,6 +53,7 @@ def test_home_page_idle_refresh_after_interval(page_ctx: PageContext) -> None:
 		patch.object(page.changes, "refresh") as changes_refresh,
 		patch.object(page.log, "refresh") as log_refresh,
 		patch.object(page.tasks, "refresh") as tasks_refresh,
+		patch.object(page.worktrees, "refresh") as worktrees_refresh,
 		patch("pygittools.tui.pages.home.time.monotonic", side_effect=[109.0, 110.0, 110.0]),
 	):
 		page.on_idle()
@@ -59,6 +62,7 @@ def test_home_page_idle_refresh_after_interval(page_ctx: PageContext) -> None:
 	changes_refresh.assert_called_once_with(page_ctx.repo)
 	log_refresh.assert_called_once_with(page_ctx.repo)
 	tasks_refresh.assert_called_once_with(page_ctx.repo)
+	worktrees_refresh.assert_called_once_with(page_ctx.repo)
 
 
 def test_home_page_handle_key_refreshes(page_ctx: PageContext) -> None:
@@ -68,11 +72,13 @@ def test_home_page_handle_key_refreshes(page_ctx: PageContext) -> None:
 		patch.object(page.changes, "refresh") as changes_refresh,
 		patch.object(page.log, "refresh") as log_refresh,
 		patch.object(page.tasks, "refresh") as tasks_refresh,
+		patch.object(page.worktrees, "refresh") as worktrees_refresh,
 	):
 		page.handle_key(key("j"))
 	changes_refresh.assert_called_once_with(page_ctx.repo)
 	log_refresh.assert_called_once_with(page_ctx.repo)
 	tasks_refresh.assert_called_once_with(page_ctx.repo)
+	worktrees_refresh.assert_called_once_with(page_ctx.repo)
 
 
 def test_home_page_draw_and_tabs(committed_repo: Repository) -> None:
@@ -84,6 +90,8 @@ def test_home_page_draw_and_tabs(committed_repo: Repository) -> None:
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_active_tab("tasks")
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	page.set_active_tab("worktrees")
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_header_focus("repo")
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.handle_key(key("Null"))
diff --git a/pygittools/tui/tabs_test.py b/pygittools/tui/tabs_test.py
index e31d3e5..f459b3c 100644
--- a/pygittools/tui/tabs_test.py
+++ b/pygittools/tui/tabs_test.py
@@ -18,7 +18,7 @@ def test_init_tab_colors_is_noop() -> None:
 def test_render_tab_row() -> None:
 	init_tui_color_theme(None)
 	frame = MagicMock()
-	render_tab_row(frame, Rect(0, 0, 80, 1), "demo", ("Status", "Log", "Tasks"), 1)
+	render_tab_row(frame, Rect(0, 0, 80, 1), "demo", ("Status", "Log", "Tasks", "Worktrees"), 1)
 	assert frame.render_widget.call_count >= 2
 
 
diff --git a/pygittools/tui/worktrees_list.py b/pygittools/tui/worktrees_list.py
new file mode 100644
index 0000000..41865bf
--- /dev/null
+++ b/pygittools/tui/worktrees_list.py
@@ -0,0 +1,160 @@
+"""Linked worktree list for the pgt TUI."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+from pygit2 import Repository
+from pyratatui import Frame, List, ListItem, ListState, Rect
+
+from pygittools.tui.git_colors import current_theme, focus_style
+from pygittools.tui.git_worktrees import WorktreeInfo, list_worktrees, remove_worktree
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_up
+from pygittools.tui.pages.worktree_add import worktree_add_page
+from pygittools.tui.types import PageAction, PageResult
+
+
+@dataclass(frozen=True, slots=True)
+class _Row:
+	kind: Literal["add", "worktree"]
+	info: WorktreeInfo | None
+
+
+class WorktreesList:
+	def __init__(self) -> None:
+		self._rows: list[_Row] = []
+		self._list_state = ListState()
+		self._error: str | None = None
+		self._current_path: Path | None = None
+
+	def is_at_top(self) -> bool:
+		return (self._list_state.selected or 0) == 0
+
+	def refresh(self, repo: Repository) -> None:
+		workdir = repo.workdir
+		self._current_path = Path(workdir).resolve() if workdir is not None else None
+		entries, error = list_worktrees(repo)
+		self._error = error
+		rows: list[_Row] = [_Row(kind="add", info=None)]
+		for info in entries:
+			rows.append(_Row(kind="worktree", info=info))
+		self._rows = rows
+		selected = self._list_state.selected or 0
+		self._list_state.select(min(selected, max(0, len(self._rows) - 1)))
+
+	def draw(self, frame: Frame, area: Rect, *, highlight: bool) -> None:
+		if area.height <= 0 or area.width <= 0:
+			return
+		theme = current_theme()
+		items: list[ListItem] = []
+		error_offset = 0
+		if self._error:
+			items.append(ListItem(self._error, theme.log_date))
+			error_offset = 1
+		selected = self._list_state.selected or 0
+		for index, row in enumerate(self._rows):
+			label = _row_label(row, current=self._current_path, width=max(10, area.width - 4))
+			focused = highlight and index == selected
+			style = focus_style(theme.normal) if focused and highlight else theme.normal
+			if row.kind == "add":
+				style = focus_style(theme.header) if focused and highlight else theme.header
+			items.append(ListItem(label, style))
+		if not self._rows and not self._error:
+			items.append(ListItem("No worktrees", theme.log_date))
+		list_state = self._list_state
+		if error_offset:
+			list_state = ListState()
+			row_selected = self._list_state.selected
+			if row_selected is not None:
+				list_state.select(row_selected + error_offset)
+		frame.render_stateful_list(
+			List(items).highlight_style(theme.focus).highlight_symbol(""),
+			area,
+			list_state,
+		)
+
+	def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
+		if self._error is not None and key.code not in {"Up", "Down", "k", "K", "j", "J"}:
+			self._error = None
+		if is_up(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(max(0, selected - 1))
+			return None
+		if is_down(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(min(max(0, len(self._rows) - 1), selected + 1))
+			return None
+		if not self._rows:
+			return None
+		row = self._rows[self._list_state.selected or 0]
+		if is_enter(key):
+			if row.kind == "add":
+				return PageResult(action=PageAction.PUSH, next_page=worktree_add_page())
+			if row.info is not None and self._current_path is not None:
+				target = row.info.path.resolve()
+				if target != self._current_path.resolve():
+					return PageResult(action=PageAction.PUSH, switch_repo=target)
+			return None
+		if key.code in {"d", "D"} and row.kind == "worktree" and row.info is not None:
+			if self._current_path is not None and row.info.path.resolve() == self._current_path.resolve():
+				self._error = "Cannot remove the active worktree"
+				return None
+			ok, error = remove_worktree(repo, row.info.path, force=True)
+			if ok:
+				self.refresh(repo)
+				return None
+			self._error = error
+		return None
+
+	def status_hint(self) -> str:
+		if self._error:
+			return self._error
+		if not self._rows:
+			return "No worktrees"
+		row = self._rows[self._list_state.selected or 0]
+		if row.kind == "add":
+			return "Enter add worktree — j/k move"
+		if row.info is not None and self._current_path is not None:
+			if row.info.path.resolve() == self._current_path.resolve():
+				return "Active worktree — d remove other worktrees — j/k move"
+			return "Enter open worktree — d remove — j/k move"
+		return "j/k move"
+
+
+def _row_label(row: _Row, *, current: Path | None, width: int) -> str:
+	if row.kind == "add":
+		return "(+ Add worktree)"
+	if row.info is None:
+		return ""
+	info = row.info
+	marker = "* " if current is not None and info.path.resolve() == current.resolve() else "  "
+	if info.bare:
+		branch = "(bare)"
+	elif info.branch:
+		branch = info.branch
+	elif info.detached:
+		branch = "(detached)"
+	else:
+		branch = "?"
+	extras: list[str] = []
+	if info.locked is not None:
+		extras.append("locked")
+	if info.prunable is not None:
+		extras.append("prunable")
+	suffix = f" [{', '.join(extras)}]" if extras else ""
+	path_str = _display_path(info.path, anchor=current)
+	label = f"{marker}{path_str}  {branch}{suffix}"
+	if len(label) > width:
+		return label[: max(0, width - 1)] + "…"
+	return label
+
+
+def _display_path(path: Path, *, anchor: Path | None) -> str:
+	if anchor is not None:
+		try:
+			return str(path.resolve().relative_to(anchor.parent.resolve()))
+		except ValueError:
+			pass
+	return str(path)
diff --git a/pygittools/tui/worktrees_list_test.py b/pygittools/tui/worktrees_list_test.py
new file mode 100644
index 0000000..cd4ef5f
--- /dev/null
+++ b/pygittools/tui/worktrees_list_test.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+from pygit2 import init_repository
+from pyratatui import Rect
+
+from pygittools.tui.git_worktrees import WorktreeInfo
+from pygittools.tui.keys import key
+from pygittools.tui.pages.worktree_add import WorktreeAddPage
+from pygittools.tui.types import PageAction, PageContext
+from pygittools.tui.worktrees_list import WorktreesList, _Row, _row_label
+
+
+def test_row_label_marks_current() -> None:
+	info = WorktreeInfo(
+		path=Path("/proj/main"),
+		head="abc",
+		branch="main",
+		bare=False,
+		detached=False,
+		locked=None,
+		prunable=None,
+	)
+	label = _row_label(_Row(kind="worktree", info=info), current=Path("/proj/main"), width=80)
+	assert label.startswith("*")
+
+
+def test_worktrees_list_open_add(page_ctx: PageContext) -> None:
+	wt = WorktreesList()
+	wt.refresh(page_ctx.repo)
+	result = wt.handle_nav_key(key("Enter"), page_ctx.repo)
+	assert result is not None
+	assert result.action == PageAction.PUSH
+	assert isinstance(result.next_page, WorktreeAddPage)
+
+
+def test_worktrees_list_switch_repo(
+	monkeypatch: pytest.MonkeyPatch,
+	tmp_path: Path,
+) -> None:
+	repo_path = tmp_path / "main"
+	repo_path.mkdir()
+	repo = init_repository(str(repo_path), bare=False)
+	other = tmp_path / "other"
+	entries = [
+		WorktreeInfo(
+			path=repo_path.resolve(),
+			head=None,
+			branch="main",
+			bare=False,
+			detached=False,
+			locked=None,
+			prunable=None,
+		),
+		WorktreeInfo(
+			path=other.resolve(),
+			head=None,
+			branch="feature",
+			bare=False,
+			detached=False,
+			locked=None,
+			prunable=None,
+		),
+	]
+	monkeypatch.setattr(
+		"pygittools.tui.worktrees_list.list_worktrees",
+		lambda _repo: (entries, None),
+	)
+	wt = WorktreesList()
+	wt.refresh(repo)
+	wt.handle_nav_key(key("Down"), repo)
+	wt.handle_nav_key(key("Down"), repo)
+	result = wt.handle_nav_key(key("Enter"), repo)
+	assert result is not None
+	assert result.switch_repo == other.resolve()
+
+
+def test_worktrees_list_draw(frame: MagicMock) -> None:
+	wt = WorktreesList()
+	wt.draw(frame, Rect(0, 0, 80, 10), highlight=True)
