diff --git a/pygittools/help.md b/pygittools/help.md
new file mode 100644
index 0000000..516804a
--- /dev/null
+++ b/pygittools/help.md
@@ -0,0 +1,52 @@
+# pgt TUI
+
+PyGitTools terminal UI for browsing a local git repository.
+
+## Home page
+
+- **J** / **Up** — move selection up (from the changes list, moves to the branch row, then the repository row)
+- **K** / **Down** — move selection down
+- **Repository row** — **Enter** opens the projects table (PyGitWeb `PROJECTROOT`)
+- **Branch row** — **Enter** opens a filterable list of local branches to check out
+- **Commit row** — type a message, then **Enter** to commit staged changes (runs git hooks)
+- **Space** / **Left** / **Right** — 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)
+- **H** — open this help page
+- **Q** — quit
+
+Commit or hook failures appear in the status bar.
+
+## Branch picker
+
+- **Type** — filter branches by name
+- **J** / **Up** — move selection up
+- **K** / **Down** — move selection down
+- **Enter** — check out the selected branch (`*` marks current), or create a new branch from the current branch when the filter matches nothing
+- **Esc** / **Q** — return to the home page
+
+Checkout failures appear in the status bar.
+
+## Projects page
+
+Lists git repositories under the PyGitWeb project directory. Settings are read from
+`~/.pygitweb/settings.json` and `PYGITWEB_*` environment variables (same as PyGitWeb):
+`PYGITWEB_PROJECTROOT`, `PYGITWEB_PROJECTS_LIST`, `PYGITWEB_PROJECT_MAXDEPTH`, and
+`PYGITWEB_LIST_ALL`.
+
+- **J** / **Up** — move selection up
+- **K** / **Down** — move selection down
+- **Enter** — open the selected project and return to the home page
+- **Esc** / **Q** — return without switching
+
+## Help page
+
+- **Up** / **K** — scroll up
+- **Down** / **J** — scroll down
+- **Q** / **Esc** — return to the previous page
+
+## Command line
+
+Run `pgt` with no arguments to start the TUI from the current directory.
+Use `pgt --repo PATH` to open a specific repository.
+Run `pgt --help` for full CLI options.
diff --git a/pygittools/main.py b/pygittools/main.py
new file mode 100644
index 0000000..b80e5a7
--- /dev/null
+++ b/pygittools/main.py
@@ -0,0 +1,42 @@
+"""Command-line entry point for pygittools (pgt)."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+import pygittools
+from pygittools.tui import run_tui
+
+
+def build_parser() -> argparse.ArgumentParser:
+	parser = argparse.ArgumentParser(
+		prog="pgt",
+		description=pygittools.__description__,
+	)
+	parser.add_argument(
+		"--repo",
+		type=Path,
+		help="Path to a git repository (default: discover from the current directory)",
+	)
+	parser.add_argument(
+		"--version",
+		action="version",
+		version=f"%(prog)s {pygittools.__version__}",
+	)
+	return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+	args_list = sys.argv[1:] if argv is None else argv
+	if not args_list:
+		return run_tui()
+
+	parser = build_parser()
+	args = parser.parse_args(args_list)
+	return run_tui(args.repo)
+
+
+if __name__ == "__main__":
+	raise SystemExit(main())
diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
index 3f09ce9..0d7484f 100644
--- a/pygittools/pyproject.toml
+++ b/pygittools/pyproject.toml
@@ -12,13 +12,17 @@ license-files = ["LICENSE"]
 requires-python = ">=3.11"
 dependencies = [
     "pygit2>=1.12.0",
+    "uni-curses>=3.1.2",
 ]
 
+[project.scripts]
+pgt = "pygittools.main:main"
+
 [tool.setuptools]
-packages = ["pygittools"]
+packages = ["pygittools", "pygittools.tui", "pygittools.tui.pages"]
 
 [tool.setuptools.package-dir]
 pygittools = "."
 
 [tool.setuptools.package-data]
-pygittools = ["hook_samples/**/*"]
+pygittools = ["hook_samples/**/*", "help.md"]
diff --git a/pygittools/tui/__init__.py b/pygittools/tui/__init__.py
new file mode 100644
index 0000000..4653229
--- /dev/null
+++ b/pygittools/tui/__init__.py
@@ -0,0 +1,5 @@
+"""pgt TUI."""
+
+from pygittools.tui.app import run_tui
+
+__all__ = ["run_tui"]
diff --git a/pygittools/tui/app.py b/pygittools/tui/app.py
new file mode 100644
index 0000000..f8c3308
--- /dev/null
+++ b/pygittools/tui/app.py
@@ -0,0 +1,106 @@
+"""pgt TUI application loop."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from unicurses import (  # type: ignore[import-untyped]
+	KEY_RESIZE,
+	cbreak,
+	clear,
+	curs_set,
+	endwin,
+	getch,
+	getmaxyx,
+	initscr,
+	keypad,
+	noecho,
+	refresh,
+)
+
+from pygittools.tui.pages.home import home_page
+from pygittools.tui.pages.no_repo import NoRepoPage
+from pygittools.tui.repo import try_open_page_context
+from pygittools.tui.status_bar import content_height, draw_status_bar, init_status_bar
+from pygittools.tui.types import Page, PageAction, PageContext, PageResult
+
+
+def run_tui(repo_path: Path | None = None) -> int:
+	start = (repo_path or Path.cwd()).resolve()
+	ctx = try_open_page_context(start)
+	initial: Page = home_page() if ctx is not None else NoRepoPage(start)
+
+	stdscr = initscr()
+	try:
+		noecho()
+		cbreak()
+		curs_set(0)
+		keypad(stdscr, True)
+		status_attr = init_status_bar()
+
+		stack: list[Page] = [initial]
+		if ctx is not None:
+			stack[0].on_enter(ctx)
+
+		while stack:
+			page = stack[-1]
+			height, width = getmaxyx(stdscr)
+			clear()
+			page.draw(stdscr, content_height(height), width)
+			if height > 0:
+				draw_status_bar(height - 1, width, page.status_text(), status_attr)
+			refresh()
+
+			try:
+				key = getch()
+			except KeyboardInterrupt:
+				break
+			if key == KEY_RESIZE:
+				continue
+
+			result = page.handle_key(key)
+			ctx, stack = _apply_result(stack, ctx, result)
+			if result.action == PageAction.QUIT:
+				break
+	except KeyboardInterrupt:
+		pass
+	finally:
+		endwin()
+
+	return 0
+
+
+def _apply_result(
+	stack: list[Page],
+	ctx: PageContext | None,
+	result: PageResult,
+) -> tuple[PageContext | None, list[Page]]:
+	if result.switch_repo is not None:
+		new_ctx = try_open_page_context(result.switch_repo)
+		if new_ctx is not None:
+			ctx = new_ctx
+
+	match result.action:
+		case PageAction.REPLACE:
+			if result.next_page is None:
+				return ctx, stack
+			if ctx is not None:
+				result.next_page.on_enter(ctx)
+			return ctx, [result.next_page]
+		case PageAction.PUSH:
+			if result.next_page is None:
+				return ctx, stack
+			if ctx is not None:
+				result.next_page.on_enter(ctx)
+			return ctx, [*stack, result.next_page]
+		case PageAction.POP:
+			if len(stack) <= 1:
+				return ctx, stack
+			stack = stack[:-1]
+		case _:
+			pass
+
+	if result.switch_repo is not None and ctx is not None and stack:
+		stack[-1].on_enter(ctx)
+
+	return ctx, stack
diff --git a/pygittools/tui/branches.py b/pygittools/tui/branches.py
new file mode 100644
index 0000000..381ea8a
--- /dev/null
+++ b/pygittools/tui/branches.py
@@ -0,0 +1,43 @@
+"""Local branch listing and checkout for the pgt TUI."""
+
+from __future__ import annotations
+
+from pygit2 import Commit, GitError, Repository, reference_is_valid_name
+
+
+def current_branch_name(repo: Repository) -> str:
+	if repo.head_is_detached:
+		return "(detached)"
+	return repo.head.shorthand or "(detached)"
+
+
+def list_local_branches(repo: Repository) -> list[str]:
+	return sorted(repo.branches.local)
+
+
+def checkout_branch(repo: Repository, branch: str) -> tuple[bool, str]:
+	try:
+		repo.checkout(f"refs/heads/{branch}")
+	except GitError as exc:
+		message = str(exc).strip()
+		return False, message or "Checkout failed"
+	return True, ""
+
+
+def create_branch_from_current(repo: Repository, name: str) -> tuple[bool, str]:
+	branch = name.strip()
+	if not branch:
+		return False, "Branch name is empty"
+	ref_name = f"refs/heads/{branch}"
+	if not reference_is_valid_name(ref_name):
+		return False, f"Invalid branch name: {branch}"
+	if branch in repo.branches.local:
+		return False, f"Branch already exists: {branch}"
+	try:
+		commit = repo.head.peel(Commit)
+		repo.branches.create(branch, commit)
+		repo.checkout(ref_name)
+	except GitError as exc:
+		message = str(exc).strip()
+		return False, message or "Failed to create branch"
+	return True, ""
diff --git a/pygittools/tui/changes_list.py b/pygittools/tui/changes_list.py
new file mode 100644
index 0000000..7234620
--- /dev/null
+++ b/pygittools/tui/changes_list.py
@@ -0,0 +1,221 @@
+"""Collapsible staged / unstaged / untracked file list with keyboard control."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Literal
+
+from pygit2 import Repository
+from unicurses import (  # type: ignore[import-untyped]
+	A_REVERSE,
+	KEY_BACKSPACE,
+	KEY_DOWN,
+	KEY_ENTER,
+	KEY_LEFT,
+	KEY_RESIZE,
+	KEY_RIGHT,
+	KEY_UP,
+)
+
+from pygittools.tui.draw import draw_line
+from pygittools.tui.worktree import (
+	categorize_status,
+	commit_staged,
+	stage_path,
+	stage_paths,
+	unstage_path,
+	unstage_paths,
+)
+
+_COMMIT_PREFIX = "Commit: "
+
+
+class SectionId(Enum):
+	STAGED = "staged"
+	UNSTAGED = "unstaged"
+	UNTRACKED = "untracked"
+
+
+@dataclass(frozen=True, slots=True)
+class _Row:
+	kind: Literal["commit", "header", "file"]
+	section: SectionId | None
+	path: str | None
+	label: str
+
+
+_SECTION_ORDER: tuple[tuple[SectionId, str], ...] = (
+	(SectionId.STAGED, "Staged"),
+	(SectionId.UNSTAGED, "Unstaged"),
+	(SectionId.UNTRACKED, "Untracked"),
+)
+
+
+class ChangesList:
+	def __init__(self) -> None:
+		self._collapsed: dict[SectionId, bool] = dict.fromkeys(SectionId, False)
+		self._cursor = 0
+		self._scroll_offset = 0
+		self._rows: list[_Row] = []
+		self._commit_message = ""
+		self._error: str | None = None
+		self._staged_count = 0
+
+	def is_at_top(self) -> bool:
+		return self._cursor == 0
+
+	def refresh(self, repo: Repository) -> None:
+		staged, unstaged, untracked = categorize_status(repo)
+		self._staged_count = len(staged)
+		files_by_section = {
+			SectionId.STAGED: staged,
+			SectionId.UNSTAGED: unstaged,
+			SectionId.UNTRACKED: untracked,
+		}
+		rows: list[_Row] = [
+			_Row(
+				kind="commit",
+				section=None,
+				path=None,
+				label=self._commit_label(width=120, focused=False),
+			),
+		]
+		for section_id, title in _SECTION_ORDER:
+			files = files_by_section[section_id]
+			marker = ">" if self._collapsed[section_id] else "v"
+			rows.append(
+				_Row(
+					kind="header",
+					section=section_id,
+					path=None,
+					label=f"[{marker}] {title} ({len(files)})",
+				),
+			)
+			if not self._collapsed[section_id]:
+				for path in files:
+					rows.append(_Row(kind="file", section=section_id, path=path, label=f"  {path}"))
+		self._rows = rows
+		self._cursor = min(self._cursor, max(0, len(self._rows) - 1))
+
+	def draw(self, top_row: int, height: int, width: int, *, highlight: bool = True) -> None:
+		if height <= 0:
+			return
+		self._ensure_cursor_visible(height)
+		for view_row in range(height):
+			row_index = self._scroll_offset + view_row
+			if row_index >= len(self._rows):
+				break
+			row = self._rows[row_index]
+			focused = row_index == self._cursor
+			label = self._commit_label(width, focused and highlight) if row.kind == "commit" else row.label
+			attr = A_REVERSE if focused and highlight else 0
+			draw_line(top_row + view_row, 0, label, width, attr)
+
+	def handle_key(self, key: int, repo: Repository) -> bool:
+		"""Handle a key press. Returns True when the list layout may have changed."""
+		if key == KEY_RESIZE:
+			return False
+		if self._error is not None and key not in (KEY_RESIZE,):
+			self._error = None
+
+		if key in (KEY_UP, ord("k"), ord("K")):
+			self._cursor = max(0, self._cursor - 1)
+			return False
+		if key in (KEY_DOWN, ord("j"), ord("J")):
+			self._cursor = min(max(0, len(self._rows) - 1), self._cursor + 1)
+			return False
+		if not self._rows:
+			return False
+
+		row = self._rows[self._cursor]
+		if row.kind == "commit":
+			return self._handle_commit_key(key, repo)
+
+		if key in (KEY_LEFT, KEY_RIGHT, ord(" ")) and row.kind == "header" and row.section is not None:
+			self._collapsed[row.section] = not self._collapsed[row.section]
+			self.refresh(repo)
+			return True
+		if key in (KEY_ENTER, 10, 13) and row.kind == "header" and row.section is not None:
+			self._toggle_section(repo, row.section)
+			self.refresh(repo)
+			return True
+		if key in (KEY_ENTER, 10, 13) and row.kind == "file" and row.path is not None and row.section is not None:
+			self._toggle_stage(repo, row.section, row.path)
+			self.refresh(repo)
+			return True
+		return False
+
+	def status_hint(self) -> str:
+		if self._error:
+			return self._error
+		if not self._rows:
+			return "Working tree clean"
+		row = self._rows[self._cursor]
+		if row.kind == "commit":
+			return "Type message — Enter commit — j/k move"
+		if row.kind == "header":
+			if row.section == SectionId.STAGED:
+				if self._staged_count == 0:
+					return "Enter stage all — Space collapse — j/k move"
+				return "Enter unstage all — Space collapse — j/k move"
+			return "Enter stage all — Space collapse — j/k move"
+		if row.section == SectionId.STAGED:
+			return "Enter unstage — j/k move"
+		return "Enter stage — j/k move"
+
+	def _handle_commit_key(self, key: int, repo: Repository) -> bool:
+		if key in (KEY_ENTER, 10, 13):
+			ok, error = commit_staged(repo, self._commit_message)
+			if ok:
+				self._commit_message = ""
+				self._error = None
+				self.refresh(repo)
+				return True
+			self._error = error
+			return False
+		if key in (KEY_BACKSPACE, 127, 8):
+			self._commit_message = self._commit_message[:-1]
+			return False
+		if 32 <= key <= 126:
+			self._commit_message += chr(key)
+			return False
+		return False
+
+	def _commit_label(self, width: int, focused: bool) -> str:
+		max_message = max(0, width - len(_COMMIT_PREFIX) - (1 if focused else 0))
+		message = self._commit_message
+		if len(message) > max_message:
+			message = message[-max_message:]
+		label = f"{_COMMIT_PREFIX}{message}"
+		if focused:
+			label += "_"
+		return label[:width]
+
+	def _toggle_stage(self, repo: Repository, section: SectionId, path: str) -> None:
+		if section == SectionId.STAGED:
+			unstage_path(repo, path)
+		else:
+			stage_path(repo, path)
+
+	def _toggle_section(self, repo: Repository, section: SectionId) -> None:
+		staged, unstaged, untracked = categorize_status(repo)
+		paths_by_section = {
+			SectionId.STAGED: staged,
+			SectionId.UNSTAGED: unstaged,
+			SectionId.UNTRACKED: untracked,
+		}
+		paths = paths_by_section[section]
+		if section == SectionId.STAGED:
+			if paths:
+				unstage_paths(repo, paths)
+			else:
+				stage_paths(repo, unstaged + untracked)
+		else:
+			stage_paths(repo, paths)
+
+	def _ensure_cursor_visible(self, height: int) -> None:
+		if self._cursor < self._scroll_offset:
+			self._scroll_offset = self._cursor
+		elif self._cursor >= self._scroll_offset + height:
+			self._scroll_offset = self._cursor - height + 1
diff --git a/pygittools/tui/draw.py b/pygittools/tui/draw.py
new file mode 100644
index 0000000..c33e470
--- /dev/null
+++ b/pygittools/tui/draw.py
@@ -0,0 +1,16 @@
+"""unicurses drawing helpers."""
+
+from __future__ import annotations
+
+from unicurses import clrtoeol, move, mvaddstr  # type: ignore[import-untyped]
+
+
+def draw_line(row: int, col: int, text: str, width: int, attr: int = 0) -> None:
+	"""Write ``text`` on one row, clipped to the terminal width."""
+	clipped = text[: max(0, width - col - 1)]
+	move(row, col)
+	clrtoeol()
+	if attr:
+		mvaddstr(row, col, clipped, attr)
+	else:
+		mvaddstr(row, col, clipped)
diff --git a/pygittools/tui/pages/__init__.py b/pygittools/tui/pages/__init__.py
new file mode 100644
index 0000000..4b4b580
--- /dev/null
+++ b/pygittools/tui/pages/__init__.py
@@ -0,0 +1,17 @@
+from pygittools.tui.pages.branches import BranchPickerPage, branch_picker_page
+from pygittools.tui.pages.help import HelpPage, help_page
+from pygittools.tui.pages.home import HomePage, home_page
+from pygittools.tui.pages.no_repo import NoRepoPage
+from pygittools.tui.pages.projects import ProjectsPage, projects_page
+
+__all__ = [
+	"BranchPickerPage",
+	"HelpPage",
+	"HomePage",
+	"NoRepoPage",
+	"ProjectsPage",
+	"branch_picker_page",
+	"help_page",
+	"home_page",
+	"projects_page",
+]
diff --git a/pygittools/tui/pages/branches.py b/pygittools/tui/pages/branches.py
new file mode 100644
index 0000000..f253013
--- /dev/null
+++ b/pygittools/tui/pages/branches.py
@@ -0,0 +1,169 @@
+"""Filterable local branch picker."""
+
+from __future__ import annotations
+
+from unicurses import (  # type: ignore[import-untyped]
+	A_BOLD,
+	A_REVERSE,
+	KEY_BACKSPACE,
+	KEY_DOWN,
+	KEY_ENTER,
+	KEY_RESIZE,
+	KEY_UP,
+)
+
+from pygittools.tui.branches import (
+	checkout_branch,
+	create_branch_from_current,
+	current_branch_name,
+	list_local_branches,
+)
+from pygittools.tui.draw import draw_line
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+_FILTER_PREFIX = "Filter: "
+
+
+class BranchPickerPage:
+	title = "branches"
+
+	def __init__(self) -> None:
+		self._ctx: PageContext | None = None
+		self._filter = ""
+		self._branches: list[str] = []
+		self._filtered: list[str] = []
+		self._cursor = 0
+		self._scroll_offset = 0
+		self._error: str | None = None
+
+	def on_enter(self, ctx: PageContext) -> None:
+		self._ctx = ctx
+		self._filter = ""
+		self._error = None
+		self._cursor = 0
+		self._scroll_offset = 0
+		self._reload_branches()
+
+	def status_text(self) -> str:
+		if self._error:
+			return self._error
+		if not self._filtered:
+			if self._filter.strip():
+				return "Enter create branch — type name — Esc back"
+			return "No matching branches — type to filter, Esc back"
+		return "Enter checkout — type to filter — j/k move — Esc back"
+
+	def draw(self, stdscr: int, height: int, width: int) -> None:
+		del stdscr
+		if height <= 0:
+			return
+		current = current_branch_name(self._ctx.repo) if self._ctx is not None else ""
+		draw_line(0, 0, "Switch branch", width, A_BOLD)
+		if height > 1:
+			draw_line(1, 0, self._filter_label(width), width, A_REVERSE if self._cursor == 0 else 0)
+		list_top = 2
+		list_height = height - list_top
+		if list_height <= 0:
+			return
+		self._ensure_cursor_visible(list_top, list_height)
+		for view_row in range(list_height):
+			index = self._scroll_offset + view_row
+			if index >= len(self._filtered):
+				break
+			branch = self._filtered[index]
+			marker = "* " if branch == current else "  "
+			row_index = index + 1
+			attr = A_REVERSE if self._cursor == row_index else 0
+			draw_line(list_top + view_row, 0, f"{marker}{branch}", width, attr)
+
+	def handle_key(self, key: int) -> PageResult:
+		if key in (ord("q"), ord("Q"), 27):
+			return PageResult(action=PageAction.POP)
+		if key == KEY_RESIZE:
+			return PageResult()
+		if self._error is not None:
+			self._error = None
+
+		ctx = self._ctx
+		if ctx is None:
+			return PageResult()
+
+		max_cursor = max(0, len(self._filtered))
+		if key in (KEY_UP, ord("k"), ord("K")):
+			self._cursor = max(0, self._cursor - 1)
+			return PageResult()
+		if key in (KEY_DOWN, ord("j"), ord("J")):
+			self._cursor = min(max_cursor, self._cursor + 1)
+			return PageResult()
+
+		if self._cursor == 0:
+			if key in (KEY_BACKSPACE, 127, 8):
+				self._filter = self._filter[:-1]
+				self._apply_filter()
+				return PageResult()
+			if 32 <= key <= 126:
+				self._filter += chr(key)
+				self._apply_filter()
+				return PageResult()
+			if key in (KEY_ENTER, 10, 13):
+				if not self._filtered:
+					return self._try_create_branch(ctx)
+				self._cursor = 1
+				return PageResult()
+
+		if key in (KEY_ENTER, 10, 13) and self._cursor > 0:
+			branch = self._filtered[self._cursor - 1]
+			ok, error = checkout_branch(ctx.repo, branch)
+			if ok:
+				return PageResult(action=PageAction.POP)
+			self._error = error
+			return PageResult()
+
+		return PageResult()
+
+	def _try_create_branch(self, ctx: PageContext) -> PageResult:
+		ok, error = create_branch_from_current(ctx.repo, self._filter)
+		if ok:
+			return PageResult(action=PageAction.POP)
+		self._error = error
+		return PageResult()
+
+	def _reload_branches(self) -> None:
+		if self._ctx is None:
+			self._branches = []
+			self._filtered = []
+			return
+		self._branches = list_local_branches(self._ctx.repo)
+		self._apply_filter()
+
+	def _apply_filter(self) -> None:
+		needle = self._filter.casefold()
+		if needle:
+			self._filtered = [branch for branch in self._branches if needle in branch.casefold()]
+		else:
+			self._filtered = list(self._branches)
+		max_cursor = max(0, len(self._filtered))
+		self._cursor = min(self._cursor, max_cursor)
+
+	def _filter_label(self, width: int) -> str:
+		max_text = max(0, width - len(_FILTER_PREFIX) - 1)
+		text = self._filter[-max_text:] if len(self._filter) > max_text else self._filter
+		label = f"{_FILTER_PREFIX}{text}"
+		if self._cursor == 0:
+			label += "_"
+		return label[:width]
+
+	def _ensure_cursor_visible(self, list_top: int, list_height: int) -> None:
+		del list_top
+		if self._cursor <= 0:
+			self._scroll_offset = 0
+			return
+		list_index = self._cursor - 1
+		if list_index < self._scroll_offset:
+			self._scroll_offset = list_index
+		elif list_index >= self._scroll_offset + list_height:
+			self._scroll_offset = list_index - list_height + 1
+
+
+def branch_picker_page() -> BranchPickerPage:
+	return BranchPickerPage()
diff --git a/pygittools/tui/pages/help.py b/pygittools/tui/pages/help.py
new file mode 100644
index 0000000..f446c24
--- /dev/null
+++ b/pygittools/tui/pages/help.py
@@ -0,0 +1,66 @@
+"""Scrollable help page backed by a bundled markdown file."""
+
+from __future__ import annotations
+
+from importlib.resources import files
+
+from unicurses import (  # type: ignore[import-untyped]
+	KEY_DOWN,
+	KEY_RESIZE,
+	KEY_UP,
+)
+
+from pygittools.tui.draw import draw_line
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+
+def _load_help_lines() -> list[str]:
+	text = files("pygittools").joinpath("help.md").read_text(encoding="utf-8")
+	return text.splitlines()
+
+
+class HelpPage:
+	title = "help"
+
+	def __init__(self) -> None:
+		self._lines = _load_help_lines()
+		self._offset = 0
+
+	def on_enter(self, ctx: PageContext) -> None:
+		del ctx
+
+	def status_text(self) -> str:
+		total = len(self._lines)
+		if total == 0:
+			return "Help"
+		visible_end = min(self._offset + 1, total)
+		return f"Help {visible_end}/{total} — J/K or arrows to scroll, Q or Esc to go back"
+
+	def draw(self, stdscr: int, height: int, width: int) -> None:
+		del stdscr
+		if height <= 0:
+			return
+		max_offset = max(0, len(self._lines) - height)
+		self._offset = min(self._offset, max_offset)
+		for row in range(height):
+			line_index = self._offset + row
+			if line_index >= len(self._lines):
+				break
+			draw_line(row, 0, self._lines[line_index], width)
+
+	def handle_key(self, key: int) -> PageResult:
+		if key in (ord("q"), ord("Q"), 27):
+			return PageResult(action=PageAction.POP)
+		if key == KEY_RESIZE:
+			return PageResult()
+		if key in (KEY_UP, ord("k"), ord("K")):
+			self._offset = max(0, self._offset - 1)
+			return PageResult()
+		if key in (KEY_DOWN, ord("j"), ord("J")):
+			self._offset += 1
+			return PageResult()
+		return PageResult()
+
+
+def help_page() -> HelpPage:
+	return HelpPage()
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
new file mode 100644
index 0000000..0587000
--- /dev/null
+++ b/pygittools/tui/pages/home.py
@@ -0,0 +1,114 @@
+"""Default landing page for the pgt TUI."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from unicurses import (  # type: ignore[import-untyped]
+	A_BOLD,
+	A_REVERSE,
+	KEY_DOWN,
+	KEY_ENTER,
+	KEY_RESIZE,
+	KEY_UP,
+)
+
+from pygittools.tui.branches import current_branch_name
+from pygittools.tui.changes_list import ChangesList
+from pygittools.tui.draw import draw_line
+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.types import Page, PageAction, PageContext, PageResult
+
+_HEADER_ROWS = 5
+_REPO_ROW = 2
+_BRANCH_ROW = 3
+_HeaderFocus = Literal["changes", "repo", "branch"]
+
+
+class HomePage:
+	title = "pgt"
+
+	def __init__(self) -> None:
+		self._ctx: PageContext | None = None
+		self._changes = ChangesList()
+		self._header_focus: _HeaderFocus = "changes"
+
+	def on_enter(self, ctx: PageContext) -> None:
+		self._ctx = ctx
+		self._header_focus = "changes"
+		self._changes.refresh(ctx.repo)
+
+	def status_text(self) -> str:
+		if self._header_focus == "repo":
+			return "Enter browse projects — j/k move"
+		if self._header_focus == "branch":
+			return "Enter switch branch — j/k move"
+		return self._changes.status_hint()
+
+	def draw(self, stdscr: int, height: int, width: int) -> None:
+		del stdscr
+		ctx = self._ctx
+		if ctx is None:
+			return
+
+		self._changes.refresh(ctx.repo)
+		worktree = ctx.repo.workdir or str(ctx.repo_path)
+		branch = current_branch_name(ctx.repo)
+		header_lines: list[tuple[str, int]] = [
+			("pgt", A_BOLD),
+			("", 0),
+			(f"Repository: {worktree}", 0),
+			(f"Branch: {branch}", 0),
+			("", 0),
+		]
+		for row, (text, attr) in enumerate(header_lines):
+			if row >= height:
+				break
+			if row == _REPO_ROW and self._header_focus == "repo":
+				attr = A_REVERSE
+			if row == _BRANCH_ROW and self._header_focus == "branch":
+				attr = A_REVERSE
+			draw_line(row, 0, text, width, attr)
+
+		list_height = max(0, height - _HEADER_ROWS)
+		if list_height > 0 and height > _HEADER_ROWS:
+			self._changes.draw(_HEADER_ROWS, list_height, width, highlight=self._header_focus == "changes")
+
+	def handle_key(self, key: int) -> PageResult:
+		if key in (ord("q"), ord("Q")):
+			return PageResult(action=PageAction.QUIT)
+		if key in (ord("h"), ord("H")):
+			return PageResult(action=PageAction.PUSH, next_page=help_page())
+		if key == KEY_RESIZE:
+			return PageResult()
+
+		if self._header_focus == "repo":
+			if key in (KEY_DOWN, ord("j"), ord("J")):
+				self._header_focus = "branch"
+			elif key in (KEY_ENTER, 10, 13):
+				return PageResult(action=PageAction.PUSH, next_page=projects_page())
+			return PageResult()
+
+		if self._header_focus == "branch":
+			if key in (KEY_UP, ord("k"), ord("K")):
+				self._header_focus = "repo"
+			elif key in (KEY_DOWN, ord("j"), ord("J")):
+				self._header_focus = "changes"
+			elif key in (KEY_ENTER, 10, 13):
+				return PageResult(action=PageAction.PUSH, next_page=branch_picker_page())
+			return PageResult()
+
+		if key in (KEY_UP, ord("k"), ord("K")) and self._changes.is_at_top():
+			self._header_focus = "branch"
+			return PageResult()
+
+		ctx = self._ctx
+		if ctx is not None:
+			self._changes.handle_key(key, ctx.repo)
+		return PageResult()
+
+
+def home_page() -> Page:
+	return HomePage()
diff --git a/pygittools/tui/pages/no_repo.py b/pygittools/tui/pages/no_repo.py
new file mode 100644
index 0000000..d0c0c8c
--- /dev/null
+++ b/pygittools/tui/pages/no_repo.py
@@ -0,0 +1,48 @@
+"""No-repository fallback page."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from unicurses import A_BOLD, KEY_RESIZE  # type: ignore[import-untyped]
+
+from pygittools.tui.draw import draw_line
+from pygittools.tui.pages.help import help_page
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+
+class NoRepoPage:
+	title = "pgt"
+
+	def __init__(self, start: Path) -> None:
+		self._start = start
+
+	def on_enter(self, ctx: PageContext) -> None:
+		del ctx
+
+	def status_text(self) -> str:
+		return "no repository"
+
+	def draw(self, stdscr: int, height: int, width: int) -> None:
+		del stdscr
+		lines: list[tuple[str, int]] = [
+			("pgt", A_BOLD),
+			("", 0),
+			(f"No git repository found under {self._start}", 0),
+			("", 0),
+			("Run pgt from inside a repository, or pass --repo PATH.", 0),
+			("", 0),
+		]
+		for row, (text, attr) in enumerate(lines):
+			if row >= height:
+				break
+			draw_line(row, 0, text, width, attr)
+
+	def handle_key(self, key: int) -> PageResult:
+		if key in (ord("q"), ord("Q")):
+			return PageResult(action=PageAction.QUIT)
+		if key in (ord("h"), ord("H")):
+			return PageResult(action=PageAction.PUSH, next_page=help_page())
+		if key == KEY_RESIZE:
+			return PageResult()
+		return PageResult()
diff --git a/pygittools/tui/pages/projects.py b/pygittools/tui/pages/projects.py
new file mode 100644
index 0000000..43b5b2a
--- /dev/null
+++ b/pygittools/tui/pages/projects.py
@@ -0,0 +1,107 @@
+"""Table of PyGitWeb projects under PROJECTROOT."""
+
+from __future__ import annotations
+
+from unicurses import (  # type: ignore[import-untyped]
+	A_BOLD,
+	A_REVERSE,
+	KEY_DOWN,
+	KEY_ENTER,
+	KEY_RESIZE,
+	KEY_UP,
+)
+
+from pygittools.tui.draw import draw_line
+from pygittools.tui.projects import ProjectEntry, list_projects
+from pygittools.tui.pygitweb_layout import load_pygitweb_layout
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+_BRANCH_WIDTH = 16
+_TABLE_TOP = 2
+
+
+class ProjectsPage:
+	title = "projects"
+
+	def __init__(self) -> None:
+		self._projects: list[ProjectEntry] = []
+		self._projectroot_label = ""
+		self._cursor = 0
+		self._scroll_offset = 0
+		self._error: str | None = None
+
+	def on_enter(self, ctx: PageContext) -> None:
+		del ctx
+		layout = load_pygitweb_layout()
+		self._projectroot_label = str(layout.projectroot)
+		self._projects = list_projects(layout)
+		self._cursor = 0
+		self._scroll_offset = 0
+		self._error = None
+
+	def status_text(self) -> str:
+		if self._error:
+			return self._error
+		if not self._projects:
+			return "No projects found — Esc back"
+		return "Enter open project — j/k move — Esc back"
+
+	def draw(self, stdscr: int, height: int, width: int) -> None:
+		del stdscr
+		if height <= 0:
+			return
+		title = f"Projects ({self._projectroot_label})"
+		draw_line(0, 0, title[:width], width, A_BOLD)
+		name_width = max(8, width - _BRANCH_WIDTH - 2)
+		header = f"{'Name':<{name_width}}  {'Branch':<{_BRANCH_WIDTH}}"
+		draw_line(1, 0, header[:width], width, A_BOLD)
+
+		list_height = height - _TABLE_TOP
+		if list_height <= 0:
+			return
+		self._ensure_cursor_visible(list_height)
+		for view_row in range(list_height):
+			index = self._scroll_offset + view_row
+			if index >= len(self._projects):
+				break
+			project = self._projects[index]
+			name = project.path
+			if len(name) > name_width:
+				name = name[: max(0, name_width - 1)] + "…"
+			branch = project.branch
+			if len(branch) > _BRANCH_WIDTH:
+				branch = branch[: max(0, _BRANCH_WIDTH - 1)] + "…"
+			line = f"{name:<{name_width}}  {branch:<{_BRANCH_WIDTH}}"
+			attr = A_REVERSE if index == self._cursor else 0
+			draw_line(_TABLE_TOP + view_row, 0, line[:width], width, attr)
+
+	def handle_key(self, key: int) -> PageResult:
+		if key in (ord("q"), ord("Q"), 27):
+			return PageResult(action=PageAction.POP)
+		if key == KEY_RESIZE:
+			return PageResult()
+		if self._error is not None:
+			self._error = None
+		if not self._projects:
+			return PageResult()
+
+		if key in (KEY_UP, ord("k"), ord("K")):
+			self._cursor = max(0, self._cursor - 1)
+			return PageResult()
+		if key in (KEY_DOWN, ord("j"), ord("J")):
+			self._cursor = min(len(self._projects) - 1, self._cursor + 1)
+			return PageResult()
+		if key in (KEY_ENTER, 10, 13):
+			project = self._projects[self._cursor]
+			return PageResult(action=PageAction.POP, switch_repo=project.worktree)
+		return PageResult()
+
+	def _ensure_cursor_visible(self, list_height: int) -> None:
+		if self._cursor < self._scroll_offset:
+			self._scroll_offset = self._cursor
+		elif self._cursor >= self._scroll_offset + list_height:
+			self._scroll_offset = self._cursor - list_height + 1
+
+
+def projects_page() -> ProjectsPage:
+	return ProjectsPage()
diff --git a/pygittools/tui/projects.py b/pygittools/tui/projects.py
new file mode 100644
index 0000000..83c2378
--- /dev/null
+++ b/pygittools/tui/projects.py
@@ -0,0 +1,111 @@
+"""Discover PyGitWeb projects (git repositories under PROJECTROOT)."""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from urllib.parse import unquote
+
+from pygit2 import GitError, Repository, discover_repository
+
+from pygittools.tui.branches import current_branch_name
+from pygittools.tui.pygitweb_layout import PygitwebLayout
+
+
+@dataclass(frozen=True, slots=True)
+class ProjectEntry:
+	path: str
+	worktree: Path
+	branch: str
+
+
+def list_projects(layout: PygitwebLayout) -> list[ProjectEntry]:
+	projects_list = layout.projects_list
+	if projects_list.is_dir():
+		raw = _find_projects_in_dir(layout)
+	elif projects_list.is_file():
+		raw = _projects_from_file(layout)
+	else:
+		raw = []
+	entries: list[ProjectEntry] = []
+	for project_path, worktree in raw:
+		entries.append(
+			ProjectEntry(
+				path=project_path,
+				worktree=worktree,
+				branch=_branch_for_worktree(worktree),
+			),
+		)
+	return sorted(entries, key=lambda entry: entry.path.casefold())
+
+
+def _branch_for_worktree(worktree: Path) -> str:
+	try:
+		repo = Repository(str(worktree))
+	except GitError:
+		return "?"
+	return current_branch_name(repo)
+
+
+def _find_projects_in_dir(layout: PygitwebLayout) -> list[tuple[str, Path]]:
+	root = layout.projects_list.resolve()
+	projectroot = layout.projectroot.resolve()
+	result: list[tuple[str, Path]] = []
+	for dirpath, dirnames, _ in os.walk(root, topdown=True):
+		rel = os.path.relpath(dirpath, root)
+		depth = 0 if rel == "." else rel.count(os.sep) + 1
+		if depth >= layout.project_maxdepth:
+			dirnames.clear()
+			continue
+		for name in list(dirnames):
+			path = Path(dirpath) / name
+			if not path.is_dir():
+				continue
+			try:
+				if not os.access(path, os.X_OK):
+					continue
+			except OSError:
+				continue
+			discovered = discover_repository(str(path))
+			if discovered is None:
+				continue
+			project_path = os.path.relpath(path, projectroot).replace("\\", "/")
+			if not layout.list_all and not _export_ok(path, layout.export_ok):
+				continue
+			worktree = path.resolve()
+			result.append((project_path, worktree))
+			dirnames.remove(name)
+	return result
+
+
+def _projects_from_file(layout: PygitwebLayout) -> list[tuple[str, Path]]:
+	projectroot = layout.projectroot.resolve()
+	result: list[tuple[str, Path]] = []
+	try:
+		lines = layout.projects_list.read_text(encoding="utf-8").splitlines()
+	except OSError:
+		return result
+	for line in lines:
+		line = line.strip()
+		if not line:
+			continue
+		parts = line.split(None, 1)
+		project_path = unquote(parts[0]) if parts else ""
+		if not project_path:
+			continue
+		worktree = (projectroot / project_path).resolve()
+		if not worktree.is_dir():
+			continue
+		if discover_repository(str(worktree)) is None:
+			continue
+		if not layout.list_all and not _export_ok(worktree, layout.export_ok):
+			continue
+		result.append((project_path, worktree))
+	return result
+
+
+def _export_ok(worktree: Path, export_ok: str) -> bool:
+	if export_ok and not (worktree / export_ok).is_file():
+		return False
+	return discover_repository(str(worktree)) is not None
diff --git a/pygittools/tui/pygitweb_layout.py b/pygittools/tui/pygitweb_layout.py
new file mode 100644
index 0000000..853456f
--- /dev/null
+++ b/pygittools/tui/pygitweb_layout.py
@@ -0,0 +1,149 @@
+"""PyGitWeb project layout settings without importing pygitweb."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+_DEFAULT_SETTINGS_PATH = Path.home() / ".pygitweb" / "settings.json"
+_ENV_PREFIX = "PYGITWEB_"
+
+
+@dataclass(frozen=True, slots=True)
+class PygitwebLayout:
+	projectroot: Path
+	projects_list: Path
+	project_maxdepth: int
+	list_all: bool
+	export_ok: str
+
+
+def load_pygitweb_layout() -> PygitwebLayout:
+	"""Load project directory settings from ~/.pygitweb/settings.json and PYGITWEB_* env vars."""
+	file_values = _load_settings_file(_settings_file_path())
+	layout = _layout_from_mapping(file_values)
+	return _apply_env_overrides(layout)
+
+
+def _settings_file_path() -> Path:
+	explicit = os.environ.get(f"{_ENV_PREFIX}SETTINGS_CONFIG", "").strip()
+	if explicit:
+		return Path(explicit).expanduser()
+	return _DEFAULT_SETTINGS_PATH
+
+
+def _load_settings_file(path: Path) -> dict[str, object]:
+	if not path.is_file():
+		return {}
+	try:
+		data = json.loads(path.read_text(encoding="utf-8"))
+	except (OSError, json.JSONDecodeError):
+		return {}
+	return data if isinstance(data, dict) else {}
+
+
+def _layout_from_mapping(values: dict[str, object]) -> PygitwebLayout:
+	projectroot = _path_value(values.get("PROJECTROOT"), Path.home())
+	projects_list_raw = values.get("PROJECTS_LIST")
+	projects_list = projectroot if projects_list_raw in (None, "") else projectroot
+	return PygitwebLayout(
+		projectroot=projectroot,
+		projects_list=projects_list if projects_list_raw is None else _path_value(projects_list_raw, projectroot),
+		project_maxdepth=_int_value(values.get("PROJECT_MAXDEPTH"), 1),
+		list_all=_bool_value(values.get("LIST_ALL"), True),
+		export_ok=_str_value(values.get("EXPORT_OK"), ""),
+	)
+
+
+def _apply_env_overrides(layout: PygitwebLayout) -> PygitwebLayout:
+	projectroot = _env_path(f"{_ENV_PREFIX}PROJECTROOT", layout.projectroot)
+	projects_list = layout.projects_list
+	if os.environ.get(f"{_ENV_PREFIX}PROJECTS_LIST", "").strip():
+		projects_list = _env_path(f"{_ENV_PREFIX}PROJECTS_LIST", layout.projects_list)
+	elif not layout.projects_list:
+		projects_list = projectroot
+	return PygitwebLayout(
+		projectroot=projectroot,
+		projects_list=projects_list,
+		project_maxdepth=_env_int(f"{_ENV_PREFIX}PROJECT_MAXDEPTH", layout.project_maxdepth),
+		list_all=_env_bool(f"{_ENV_PREFIX}LIST_ALL", layout.list_all),
+		export_ok=_env_str(f"{_ENV_PREFIX}EXPORT_OK", layout.export_ok),
+	)
+
+
+def _path_value(value: object, default: Path) -> Path:
+	if value is None or value == "":
+		return default
+	return Path(str(value)).expanduser()
+
+
+def _str_value(value: object, default: str) -> str:
+	if value is None:
+		return default
+	return str(value)
+
+
+def _int_value(value: object, default: int) -> int:
+	if value is None:
+		return default
+	if isinstance(value, bool):
+		return int(value)
+	if isinstance(value, int):
+		return value
+	if isinstance(value, str):
+		try:
+			return int(value.strip())
+		except ValueError:
+			return default
+	if isinstance(value, float):
+		return int(value)
+	return default
+
+
+def _bool_value(value: object, default: bool) -> bool:
+	if value is None:
+		return default
+	if isinstance(value, bool):
+		return value
+	if isinstance(value, int):
+		return value != 0
+	if isinstance(value, str):
+		normalized = value.strip().lower()
+		if normalized in ("false", "0", "no", "off"):
+			return False
+		if normalized in ("true", "1", "yes", "on"):
+			return True
+	return default
+
+
+def _env_str(name: str, default: str) -> str:
+	raw = os.environ.get(name)
+	if raw is None or raw.strip() == "":
+		return default
+	return raw
+
+
+def _env_int(name: str, default: int) -> int:
+	raw = os.environ.get(name)
+	if raw is None or raw.strip() == "":
+		return default
+	try:
+		return int(raw.strip())
+	except ValueError:
+		return default
+
+
+def _env_bool(name: str, default: bool) -> bool:
+	raw = os.environ.get(name)
+	if raw is None or raw.strip() == "":
+		return default
+	return _bool_value(raw, default)
+
+
+def _env_path(name: str, default: Path) -> Path:
+	raw = os.environ.get(name)
+	if raw is None or raw.strip() == "":
+		return default
+	return Path(raw.strip()).expanduser()
diff --git a/pygittools/tui/repo.py b/pygittools/tui/repo.py
new file mode 100644
index 0000000..a2f91a6
--- /dev/null
+++ b/pygittools/tui/repo.py
@@ -0,0 +1,26 @@
+"""Repository discovery for the pgt TUI."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from pygit2 import GitError, Repository, discover_repository
+
+from pygittools.tui.types import PageContext
+
+
+def open_page_context(start: Path | None = None) -> PageContext:
+	"""Discover a git repository from ``start`` (or the current directory)."""
+	root = (start or Path.cwd()).resolve()
+	discovered = discover_repository(str(root))
+	if discovered is None:
+		raise GitError(f"No git repository found under {root}")
+	repo_path = Path(discovered)
+	return PageContext(repo=Repository(str(repo_path)), repo_path=repo_path)
+
+
+def try_open_page_context(start: Path | None = None) -> PageContext | None:
+	try:
+		return open_page_context(start)
+	except GitError:
+		return None
diff --git a/pygittools/tui/status_bar.py b/pygittools/tui/status_bar.py
new file mode 100644
index 0000000..c1c1a39
--- /dev/null
+++ b/pygittools/tui/status_bar.py
@@ -0,0 +1,53 @@
+"""Bottom status bar shared by all pages."""
+
+from __future__ import annotations
+
+from unicurses import (  # type: ignore[import-untyped]
+	A_REVERSE,
+	COLOR_BLUE,
+	COLOR_PAIR,
+	COLOR_WHITE,
+	has_colors,
+	init_pair,
+	mvaddstr,
+	start_color,
+	use_default_colors,
+)
+
+_STATUS_PAIR = 1
+_DEFAULT_HINT = "q quit"
+
+
+def init_status_bar() -> int:
+	"""Initialize status bar colors and return the text attribute to use."""
+	if has_colors():
+		start_color()
+		use_default_colors()
+		init_pair(_STATUS_PAIR, COLOR_WHITE, COLOR_BLUE)
+		return COLOR_PAIR(_STATUS_PAIR)
+	return A_REVERSE
+
+
+def content_height(full_height: int) -> int:
+	"""Rows available to page content above the status bar."""
+	return max(0, full_height - 1)
+
+
+def draw_status_bar(row: int, width: int, text: str, attr: int, hint: str = _DEFAULT_HINT) -> None:
+	"""Draw the status bar on the last screen row."""
+	if width <= 0:
+		return
+	line = _format_line(text, hint, width)
+	mvaddstr(row, 0, line, attr)
+
+
+def _format_line(left: str, right: str, width: int) -> str:
+	if not right:
+		return left[:width].ljust(width)
+	if len(right) >= width:
+		return right[:width].ljust(width)
+	space = width - len(right)
+	if space <= 1:
+		return right[:width].ljust(width)
+	clipped_left = left[: space - 1]
+	return f"{clipped_left:<{space - 1}} {right}"[:width].ljust(width)
diff --git a/pygittools/tui/types.py b/pygittools/tui/types.py
new file mode 100644
index 0000000..57ec8e4
--- /dev/null
+++ b/pygittools/tui/types.py
@@ -0,0 +1,47 @@
+"""Types for pgt TUI pages and navigation."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum, auto
+from pathlib import Path
+from typing import Protocol, runtime_checkable
+
+from pygit2 import Repository
+
+
+class PageAction(Enum):
+	NONE = auto()
+	QUIT = auto()
+	REPLACE = auto()
+	PUSH = auto()
+	POP = auto()
+
+
+@dataclass(frozen=True, slots=True)
+class PageResult:
+	action: PageAction = PageAction.NONE
+	next_page: Page | None = None
+	switch_repo: Path | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class PageContext:
+	repo: Repository
+	repo_path: Path
+
+
+@runtime_checkable
+class Page(Protocol):
+	"""A full-screen view in the pgt TUI."""
+
+	@property
+	def title(self) -> str: ...
+
+	def on_enter(self, ctx: PageContext) -> None: ...
+
+	def draw(self, stdscr: int, height: int, width: int) -> None: ...
+
+	def status_text(self) -> str: ...
+
+	def handle_key(self, key: int) -> PageResult: ...
diff --git a/pygittools/tui/worktree.py b/pygittools/tui/worktree.py
new file mode 100644
index 0000000..61a52c0
--- /dev/null
+++ b/pygittools/tui/worktree.py
@@ -0,0 +1,102 @@
+"""Working tree status and index helpers for the pgt TUI."""
+
+from __future__ import annotations
+
+import subprocess
+
+from pygit2 import Commit, IndexEntry, Repository
+from pygit2.enums import FileStatus
+
+from pygittools.hooks_push import git_executable
+
+_INDEX_FLAGS = (
+	FileStatus.INDEX_NEW
+	| FileStatus.INDEX_MODIFIED
+	| FileStatus.INDEX_DELETED
+	| FileStatus.INDEX_RENAMED
+	| FileStatus.INDEX_TYPECHANGE
+)
+_WT_FLAGS = (
+	FileStatus.WT_MODIFIED
+	| FileStatus.WT_DELETED
+	| FileStatus.WT_TYPECHANGE
+	| FileStatus.WT_RENAMED
+	| FileStatus.WT_UNREADABLE
+)
+
+
+def categorize_status(repo: Repository) -> tuple[list[str], list[str], list[str]]:
+	"""Return staged, unstaged, and untracked paths from ``repo.status()``."""
+	status = repo.status()
+	staged = sorted(path for path, flags in status.items() if flags & _INDEX_FLAGS)
+	unstaged = sorted(path for path, flags in status.items() if flags & _WT_FLAGS)
+	untracked = sorted(path for path, flags in status.items() if flags & FileStatus.WT_NEW)
+	return staged, unstaged, untracked
+
+
+def stage_path(repo: Repository, path: str) -> None:
+	index = repo.index
+	index.add(path)
+	index.write()
+
+
+def stage_paths(repo: Repository, paths: list[str]) -> None:
+	if not paths:
+		return
+	index = repo.index
+	for path in paths:
+		index.add(path)
+	index.write()
+
+
+def unstage_path(repo: Repository, path: str) -> None:
+	index = repo.index
+	head_tree = repo.head.peel(Commit).tree
+	if path in head_tree:
+		entry = head_tree[path]
+		index.add(IndexEntry(path, entry.id, entry.filemode))
+	else:
+		index.remove(path)
+	index.write()
+
+
+def unstage_paths(repo: Repository, paths: list[str]) -> None:
+	if not paths:
+		return
+	index = repo.index
+	head_tree = repo.head.peel(Commit).tree
+	for path in paths:
+		if path in head_tree:
+			entry = head_tree[path]
+			index.add(IndexEntry(path, entry.id, entry.filemode))
+		else:
+			index.remove(path)
+	index.write()
+
+
+def commit_staged(repo: Repository, message: str) -> tuple[bool, str]:
+	"""Create a commit via the git CLI so client hooks run. Returns (ok, error)."""
+	trimmed = message.strip()
+	if not trimmed:
+		return False, "Commit message is empty"
+	staged, _, _ = categorize_status(repo)
+	if not staged:
+		return False, "Nothing staged to commit"
+	workdir = repo.workdir
+	if workdir is None:
+		return False, "Repository has no working directory"
+
+	result = subprocess.run(
+		[git_executable(), "commit", "-m", trimmed],
+		cwd=workdir,
+		capture_output=True,
+		text=True,
+		check=False,
+	)
+	if result.returncode == 0:
+		return True, ""
+
+	output = (result.stderr or result.stdout or "Commit failed").strip()
+	if not output:
+		return False, "Commit failed"
+	return False, output.splitlines()[-1]
diff --git a/uv.lock b/uv.lock
index 14c8a04..af2e766 100644
--- a/uv.lock
+++ b/uv.lock
@@ -940,10 +940,14 @@ version = "0.1.0"
 source = { editable = "pygittools" }
 dependencies = [
     { name = "pygit2" },
+    { name = "uni-curses" },
 ]
 
 [package.metadata]
-requires-dist = [{ name = "pygit2", specifier = ">=1.12.0" }]
+requires-dist = [
+    { name = "pygit2", specifier = ">=1.12.0" },
+    { name = "uni-curses", specifier = ">=3.1.2" },
+]
 
 [[package]]
 name = "pygitweb"
@@ -1278,6 +1282,15 @@ wheels = [
 ]
 
 [[package]]
+name = "uni-curses"
+version = "3.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "x256" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/6a/3e66a35990254e81f66a0989471429a140d0adc518c1d9ea9b8a0912f5cd/Uni-Curses-3.1.2.tar.gz", hash = "sha256:3389a3766cb63527f92a24d56339cfcbce9b560641e6ed6f0e2e86207d45adce", size = 3660518, upload-time = "2024-07-05T09:51:18.249Z" }
+
+[[package]]
 name = "uvicorn"
 version = "0.44.0"
 source = { registry = "https://pypi.org/simple" }
@@ -1484,3 +1497,9 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
     { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
 ]
+
+[[package]]
+name = "x256"
+version = "0.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/f5/2be54b37a736ede81168fa2bc4ac43655a3fecab03f903a82ecd0560a821/x256-0.0.3.tar.gz", hash = "sha256:f855dbccd91e53f5890283d8203855743827e7eed595d5cf19544ba3d212e001", size = 3774, upload-time = "2013-12-11T21:33:58.034Z" }
