diff --git a/pygittools/tui/app_test.py b/pygittools/tui/app_test.py
new file mode 100644
index 0000000..a756b6b
--- /dev/null
+++ b/pygittools/tui/app_test.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from pygit2 import Repository, init_repository
+
+from pygittools.tui.app import _apply_result, _persist_commit_draft, run_tui
+from pygittools.tui.pages.home import home_page
+from pygittools.tui.pages.no_repo import NoRepoPage
+from pygittools.tui.types import PageAction, PageContext, PageResult
+
+
+def test_persist_commit_draft_skips_without_context() -> None:
+	_persist_commit_draft([], None)
+
+
+def test_persist_commit_draft_from_home_page(page_ctx: PageContext) -> None:
+	page = home_page()
+	page.on_enter(page_ctx)
+	page.changes.set_commit_message("draft")
+	_persist_commit_draft([page], page_ctx)
+	from pygittools.tui.commit_draft import load_commit_draft
+
+	assert load_commit_draft(page_ctx.repo_path, page_ctx.repo) == "draft"
+
+
+def test_apply_result_replace(page_ctx: PageContext) -> None:
+	next_page = home_page()
+	stack = [NoRepoPage(Path("/tmp"))]
+	ctx, new_stack = _apply_result(
+		stack,
+		page_ctx,
+		PageResult(action=PageAction.REPLACE, next_page=next_page),
+	)
+	assert ctx is page_ctx
+	assert new_stack == [next_page]
+
+
+def test_apply_result_replace_without_page(page_ctx: PageContext) -> None:
+	stack = [home_page()]
+	ctx, new_stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.REPLACE))
+	assert new_stack is stack
+
+
+def test_apply_result_push_and_pop(page_ctx: PageContext) -> None:
+	home = home_page()
+	home.on_enter(page_ctx)
+	stack = [home]
+	overlay = NoRepoPage(Path("/tmp"))
+	ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.PUSH, next_page=overlay))
+	assert len(stack) == 2
+	ctx, stack = _apply_result(stack, page_ctx, PageResult(action=PageAction.POP))
+	assert len(stack) == 1
+
+
+def test_apply_result_pop_keeps_root(page_ctx: PageContext) -> None:
+	home = home_page()
+	ctx, stack = _apply_result([home], page_ctx, PageResult(action=PageAction.POP))
+	assert stack == [home]
+
+
+def test_apply_result_switch_repo(page_ctx: PageContext, tmp_path: Path) -> None:
+	repo_dir = tmp_path / "other"
+	repo_dir.mkdir()
+	init_repository(str(repo_dir), bare=False)
+	home = home_page()
+	home.on_enter(page_ctx)
+	ctx, stack = _apply_result(
+		[home],
+		page_ctx,
+		PageResult(action=PageAction.PUSH, switch_repo=repo_dir),
+	)
+	assert ctx is not None
+	assert stack[-1] is home
+
+
+def test_apply_result_switch_repo_reenters_page(page_ctx: PageContext, tmp_path: Path) -> None:
+	repo_dir = tmp_path / "other"
+	repo_dir.mkdir()
+	init_repository(str(repo_dir), bare=False)
+	home = home_page()
+	home.on_enter(page_ctx)
+	ctx, stack = _apply_result(
+		[home],
+		page_ctx,
+		PageResult(action=PageAction.NONE, switch_repo=repo_dir),
+	)
+	assert ctx is not None
+	assert stack[-1] is home
+
+
+def _run_tui_once(repo_dir: Path, keys: list[int]) -> int:
+	with patch("pygittools.tui.app.initscr") as initscr:
+		initscr.return_value = MagicMock()
+		with (
+			patch("pygittools.tui.app.getmaxyx", return_value=(24, 80)),
+			patch("pygittools.tui.app.getch", side_effect=keys),
+			patch("pygittools.tui.app.noecho"),
+			patch("pygittools.tui.app.cbreak"),
+			patch("pygittools.tui.app.curs_set"),
+			patch("pygittools.tui.app.keypad"),
+			patch("pygittools.tui.app.clear"),
+			patch("pygittools.tui.app.refresh"),
+			patch("pygittools.tui.app.endwin"),
+			patch("pygittools.tui.app.init_status_bar", return_value=0),
+			patch("pygittools.tui.app.init_tab_colors"),
+			patch("pygittools.tui.app.draw_status_bar"),
+		):
+			return run_tui(repo_dir)
+
+
+def test_run_tui_quits_on_q(committed_repo: Repository) -> None:
+	assert _run_tui_once(Path(committed_repo.workdir), [ord("q")]) == 0
+
+
+def test_run_tui_no_repo(tmp_path: Path) -> None:
+	assert _run_tui_once(tmp_path, [ord("q")]) == 0
diff --git a/pygittools/tui/branches.py b/pygittools/tui/branches.py
index 381ea8a..0c655bf 100644
--- a/pygittools/tui/branches.py
+++ b/pygittools/tui/branches.py
@@ -18,7 +18,7 @@ def list_local_branches(repo: Repository) -> list[str]:
 def checkout_branch(repo: Repository, branch: str) -> tuple[bool, str]:
 	try:
 		repo.checkout(f"refs/heads/{branch}")
-	except GitError as exc:
+	except (GitError, KeyError) as exc:
 		message = str(exc).strip()
 		return False, message or "Checkout failed"
 	return True, ""
diff --git a/pygittools/tui/branches_test.py b/pygittools/tui/branches_test.py
new file mode 100644
index 0000000..4b7faec
--- /dev/null
+++ b/pygittools/tui/branches_test.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import pytest
+from pygit2 import GitError, Repository
+
+from pygittools.tui.branches import (
+	checkout_branch,
+	create_branch_from_current,
+	current_branch_name,
+	list_local_branches,
+)
+
+
+def test_current_branch_name_on_main(committed_repo: Repository) -> None:
+	name = current_branch_name(committed_repo)
+	assert name in {"main", "master"}
+
+
+def test_current_branch_name_when_detached(committed_repo: Repository) -> None:
+	commit = committed_repo.get(committed_repo.head.target)
+	committed_repo.set_head(commit.id)
+	assert current_branch_name(committed_repo) == "(detached)"
+
+
+def test_list_local_branches_sorted(committed_repo: Repository) -> None:
+	commit = committed_repo.get(committed_repo.head.target)
+	committed_repo.branches.create("feature", commit)
+	branches = list_local_branches(committed_repo)
+	assert branches == sorted(branches)
+	assert "feature" in branches
+
+
+def test_checkout_branch_switches(committed_repo: Repository) -> None:
+	commit = committed_repo.get(committed_repo.head.target)
+	committed_repo.branches.create("feature", commit)
+	ok, error = checkout_branch(committed_repo, "feature")
+	assert ok is True
+	assert error == ""
+	assert current_branch_name(committed_repo) == "feature"
+
+
+def test_checkout_branch_returns_error_for_missing_branch(committed_repo: Repository) -> None:
+	ok, error = checkout_branch(committed_repo, "missing")
+	assert ok is False
+	assert error
+
+
+def test_create_branch_from_current_success(committed_repo: Repository) -> None:
+	ok, error = create_branch_from_current(committed_repo, "new-branch")
+	assert ok is True
+	assert error == ""
+	assert "new-branch" in list_local_branches(committed_repo)
+
+
+def test_create_branch_rejects_empty_name(committed_repo: Repository) -> None:
+	ok, error = create_branch_from_current(committed_repo, "   ")
+	assert ok is False
+	assert error == "Branch name is empty"
+
+
+def test_create_branch_rejects_invalid_name(committed_repo: Repository) -> None:
+	ok, error = create_branch_from_current(committed_repo, "bad..name")
+	assert ok is False
+	assert "Invalid branch name" in error
+
+
+def test_create_branch_rejects_duplicate(committed_repo: Repository) -> None:
+	ok, _ = create_branch_from_current(committed_repo, "dup")
+	assert ok is True
+	ok, error = create_branch_from_current(committed_repo, "dup")
+	assert ok is False
+	assert "already exists" in error
+
+
+def test_create_branch_surfaces_git_error(monkeypatch: pytest.MonkeyPatch, committed_repo: Repository) -> None:
+	def fail_create(_name: str, _commit: object) -> None:
+		raise GitError("boom")
+
+	monkeypatch.setattr(committed_repo.branches, "create", fail_create)
+	ok, error = create_branch_from_current(committed_repo, "broken")
+	assert ok is False
+	assert error == "boom"
+
+
+def test_checkout_branch_empty_git_error_message(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	def fail_checkout(_ref: str) -> None:
+		raise GitError("   ")
+
+	monkeypatch.setattr(committed_repo, "checkout", fail_checkout)
+	ok, error = checkout_branch(committed_repo, "main")
+	assert ok is False
+	assert error == "Checkout failed"
diff --git a/pygittools/tui/changes_list_test.py b/pygittools/tui/changes_list_test.py
new file mode 100644
index 0000000..b9e1eec
--- /dev/null
+++ b/pygittools/tui/changes_list_test.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pygit2 import Repository
+from unicurses import KEY_BACKSPACE, KEY_DOWN, KEY_ENTER, KEY_RESIZE, KEY_UP
+
+from pygittools.tui.changes_list import ChangesList, SectionId
+from pygittools.tui.types import PageContext
+from pygittools.tui.worktree import categorize_status
+
+
+def test_refresh_builds_sections(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	assert changes.status_hint() != ""
+
+
+def test_refresh_clean_repo(page_ctx: PageContext) -> None:
+	changes = ChangesList()
+	changes.refresh(page_ctx.repo)
+	assert changes.status_hint() == "Enter type message — j/k move"
+
+
+def test_nav_up_down(page_ctx: PageContext) -> None:
+	changes = ChangesList()
+	changes.refresh(page_ctx.repo)
+	changes.handle_nav_key(KEY_DOWN, page_ctx.repo)
+	changes.handle_nav_key(KEY_UP, page_ctx.repo)
+	assert changes.is_at_top()
+
+
+def test_nav_resize_is_noop(page_ctx: PageContext) -> None:
+	changes = ChangesList()
+	assert changes.handle_nav_key(KEY_RESIZE, page_ctx.repo).layout_changed is False
+
+
+def test_stage_and_unstage_file(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "tracked.txt"
+	path.write_text("v2\n", encoding="utf-8")
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes._cursor = 3
+	changes.handle_nav_key(KEY_ENTER, committed_repo)
+	changes.refresh(committed_repo)
+	staged, unstaged, _ = categorize_status(committed_repo)
+	assert "tracked.txt" in staged or "tracked.txt" in unstaged
+
+
+def test_toggle_section_stages_untracked(committed_repo: Repository) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header" and row.section == SectionId.UNTRACKED:
+			changes._cursor = index
+			break
+	result = changes.handle_nav_key(KEY_ENTER, committed_repo)
+	assert result.layout_changed is True
+
+
+def test_collapse_section_with_space(committed_repo: Repository) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header" and row.section == SectionId.UNSTAGED:
+			changes._cursor = index
+			break
+	result = changes.handle_nav_key(ord(" "), committed_repo)
+	assert result.layout_changed is True
+
+
+def test_commit_input_flow(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	committed_repo.index.add("new.txt")
+	committed_repo.index.write()
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	changes.handle_nav_key(ord("f"), committed_repo)
+	assert changes.commit_message == "f"
+	changes.handle_input_key(KEY_BACKSPACE, committed_repo)
+	assert changes.commit_message == ""
+	monkeypatch.setattr(
+		"pygittools.tui.changes_list.commit_staged",
+		lambda _repo, _msg: (True, ""),
+	)
+	result = changes.handle_input_key(KEY_ENTER, committed_repo)
+	assert result.relinquish is True
+
+
+def test_commit_empty_shows_error(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	changes.handle_input_key(KEY_ENTER, committed_repo)
+	assert changes.status_hint() == "Commit message is empty"
+
+
+def test_commit_from_nav_row(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	committed_repo.index.add("new.txt")
+	committed_repo.index.write()
+	changes = ChangesList()
+	changes.set_commit_message("ship it")
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	monkeypatch.setattr(
+		"pygittools.tui.changes_list.commit_staged",
+		lambda _repo, _msg: (True, ""),
+	)
+	result = changes.handle_nav_key(KEY_ENTER, committed_repo)
+	assert result.layout_changed is True
+
+
+def test_enter_on_empty_commit_enters_input(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	result = changes.handle_nav_key(KEY_ENTER, committed_repo)
+	assert result.enter_input is True
+
+
+def test_status_hints_for_sections(committed_repo: Repository) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header" and row.section == SectionId.STAGED:
+			changes._cursor = index
+			assert "stage all" in changes.status_hint()
+		if row.kind == "file" and row.section == SectionId.UNTRACKED:
+			changes._cursor = index
+			assert "Enter stage" in changes.status_hint()
+
+
+def test_draw_and_scroll(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes.draw(0, 0, 80)
+	changes._cursor = 50
+	changes.draw(0, 5, 80, highlight=True, commit_input=True)
+
+
+def test_persist_commit_draft(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.set_commit_message("draft")
+	changes.persist_commit_draft(committed_repo)
+	from pygittools.tui.commit_draft import load_commit_draft
+
+	assert load_commit_draft(Path(committed_repo.path), committed_repo) == "draft"
+
+
+def test_unstage_all_from_empty_staged_header(committed_repo: Repository) -> None:
+	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header" and row.section == SectionId.STAGED:
+			changes._cursor = index
+			break
+	result = changes.handle_nav_key(KEY_ENTER, committed_repo)
+	assert result.layout_changed is True
+
+
+def test_long_commit_message_truncates_in_label(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.set_commit_message("x" * 200)
+	label = changes._commit_label(40, focused=True)
+	assert len(label) <= 40
diff --git a/pygittools/tui/commit_draft_test.py b/pygittools/tui/commit_draft_test.py
index cc28e75..630e055 100644
--- a/pygittools/tui/commit_draft_test.py
+++ b/pygittools/tui/commit_draft_test.py
@@ -62,3 +62,27 @@ def test_load_returns_draft_when_it_differs_from_head(repo: Repository) -> None:
 	save_commit_draft(Path(repo.path), "feat: next change")
 
 	assert load_commit_draft(Path(repo.path), repo) == "feat: next change"
+
+
+def test_read_draft_ignores_unreadable_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	path = commit_editmsg_path(tmp_path)
+	path.write_text("draft\n", encoding="utf-8")
+
+	def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
+		raise OSError("nope")
+
+	monkeypatch.setattr(Path, "read_text", fail_read_text)
+	assert load_commit_draft(tmp_path) == ""
+
+
+def test_load_returns_draft_when_head_unavailable(repo: Repository) -> None:
+	save_commit_draft(Path(repo.path), "wip")
+	assert load_commit_draft(Path(repo.path), repo) == "wip"
+
+
+def test_save_commit_draft_ignores_os_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	def fail_write_text(self: Path, data: str, encoding: str = "utf-8") -> None:
+		raise OSError("disk full")
+
+	monkeypatch.setattr(Path, "write_text", fail_write_text)
+	save_commit_draft(tmp_path, "ignored")
diff --git a/pygittools/tui/conftest.py b/pygittools/tui/conftest.py
new file mode 100644
index 0000000..fd58dde
--- /dev/null
+++ b/pygittools/tui/conftest.py
@@ -0,0 +1,53 @@
+"""Shared fixtures for pgt TUI tests."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+from pygit2 import Repository, Signature, init_repository
+
+from pygittools.tui.types import PageContext
+
+SIG = Signature("tester", "tester@example.com", 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)
+
+
+@pytest.fixture
+def page_ctx(repo: Repository) -> PageContext:
+	return PageContext(repo=repo, repo_path=Path(repo.path))
+
+
+@pytest.fixture
+def committed_repo(repo: Repository) -> Repository:
+	workdir = Path(repo.workdir)
+	(workdir / "tracked.txt").write_text("v1\n", encoding="utf-8")
+	repo.index.add("tracked.txt")
+	repo.index.write()
+	tree = repo.index.write_tree()
+	repo.create_commit("HEAD", SIG, SIG, "initial", tree, [])
+	return repo
+
+
+@pytest.fixture(autouse=True)
+def _mock_unicurses_io(monkeypatch: pytest.MonkeyPatch) -> None:
+	"""Avoid requiring a real terminal for status/tab helpers."""
+	mock = MagicMock()
+	for name in (
+		"has_colors",
+		"start_color",
+		"use_default_colors",
+		"init_pair",
+	):
+		monkeypatch.setattr(f"pygittools.tui.status_bar.{name}", mock, raising=False)
+		monkeypatch.setattr(f"pygittools.tui.tabs.{name}", mock, raising=False)
+	for name in ("move", "clrtoeol", "mvaddstr"):
+		monkeypatch.setattr(f"pygittools.tui.draw.{name}", mock, raising=False)
+		monkeypatch.setattr(f"pygittools.tui.status_bar.{name}", mock, raising=False)
diff --git a/pygittools/tui/coverage_gaps_test.py b/pygittools/tui/coverage_gaps_test.py
new file mode 100644
index 0000000..8c7c9d5
--- /dev/null
+++ b/pygittools/tui/coverage_gaps_test.py
@@ -0,0 +1,298 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from pygit2 import Repository, init_repository
+from unicurses import KEY_BACKSPACE, KEY_ENTER, KEY_RESIZE, KEY_RIGHT, KEY_UP
+
+from pygittools.tui.app import _apply_result, run_tui
+from pygittools.tui.changes_list import ChangesList, SectionId
+from pygittools.tui.commit_draft import commit_editmsg_path, load_commit_draft
+from pygittools.tui.input_context import InputContext, nav_context
+from pygittools.tui.input_context_test import FakeHost
+from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.pages.branches import BranchPickerPage
+from pygittools.tui.pages.help import HelpPage
+from pygittools.tui.pages.home import home_page
+from pygittools.tui.pages.no_repo import NoRepoPage
+from pygittools.tui.pages.projects import ProjectsPage
+from pygittools.tui.projects import ProjectEntry
+from pygittools.tui.pygitweb_layout import _bool_value, _int_value, _layout_from_mapping
+from pygittools.tui.status_bar import _format_line
+from pygittools.tui.tabs import _clip_prefix, tab_row_layout
+from pygittools.tui.types import PageAction, PageContext, PageResult
+from pygittools.tui.worktree import commit_staged, unstage_paths
+
+
+def test_read_draft_skips_blank_lines(tmp_path: Path) -> None:
+	commit_editmsg_path(tmp_path).write_text("\n\n# comment\n", encoding="utf-8")
+	assert load_commit_draft(tmp_path) == ""
+
+
+def test_draw_text_zero_clip_width(monkeypatch: pytest.MonkeyPatch) -> None:
+	from pygittools.tui import draw
+
+	monkeypatch.setattr(draw, "move", MagicMock())
+	monkeypatch.setattr(draw, "mvaddstr", MagicMock())
+	assert draw.draw_text(0, 9, "x", 10) == 9
+
+
+def test_status_bar_tight_left_space() -> None:
+	line = _format_line("left", "hint", 5)
+	assert line == "hint "
+
+
+def test_tab_row_layout_multiple_tabs() -> None:
+	layout = tab_row_layout("p", ("A", "B", "C"))
+	assert len(layout) == 3
+
+
+def test_clip_prefix_fits_without_ellipsis() -> None:
+	assert _clip_prefix("x", 80, ("Status", "Log")) == "x"
+
+
+def test_layout_from_mapping_empty_projects_list() -> None:
+	layout = _layout_from_mapping({"PROJECTROOT": "/tmp", "PROJECTS_LIST": ""})
+	assert layout.projects_list == Path("/tmp")
+
+
+def test_int_and_bool_defaults() -> None:
+	assert _int_value(object(), 9) == 9
+	assert _bool_value(object(), False) is False
+
+
+def test_changes_list_working_tree_clean_hint() -> None:
+	changes = ChangesList()
+	assert changes.status_hint() == "Working tree clean"
+
+
+def test_changes_list_input_resize_and_unknown_key(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	assert changes.handle_input_key(KEY_RESIZE, committed_repo).layout_changed is False
+	assert changes.handle_input_key(999, committed_repo).layout_changed is False
+
+
+def test_changes_list_nav_with_no_rows_after_collapse(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes._rows = []
+	assert changes.handle_nav_key(KEY_UP, committed_repo).layout_changed is False
+
+
+def test_changes_list_commit_hints_and_backspace(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.set_commit_message("msg")
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	assert "Enter commit" in changes.status_hint()
+	changes.handle_nav_key(KEY_BACKSPACE, committed_repo)
+	changes.handle_nav_key(KEY_BACKSPACE, committed_repo)
+
+
+def test_changes_list_staged_header_unstage_all(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	committed_repo.index.add("new.txt")
+	committed_repo.index.write()
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header" and row.section == SectionId.STAGED:
+			changes._cursor = index
+			break
+	changes.handle_nav_key(KEY_ENTER, committed_repo)
+
+
+def test_changes_list_unstage_file_row(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	committed_repo.index.add("new.txt")
+	committed_repo.index.write()
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "file" and row.section == SectionId.STAGED:
+			changes._cursor = index
+			changes.handle_nav_key(KEY_ENTER, committed_repo)
+			break
+
+
+def test_changes_list_header_right_key(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	for index, row in enumerate(changes._rows):
+		if row.kind == "header":
+			changes._cursor = index
+			changes.handle_nav_key(KEY_RIGHT, committed_repo)
+			break
+
+
+def test_changes_list_failed_commit_nav(committed_repo: Repository) -> None:
+	changes = ChangesList()
+	changes.set_commit_message("msg")
+	changes.refresh(committed_repo)
+	changes._cursor = 0
+	changes.handle_nav_key(KEY_ENTER, committed_repo)
+	assert changes.status_hint()
+
+
+def test_log_list_scroll_and_refresh(committed_repo: Repository) -> None:
+	log = CommitLogList()
+	log.refresh(committed_repo)
+	log.draw(0, 0, 80, highlight=True)
+	log._cursor = 100
+	log.draw(0, 2, 80, highlight=True)
+	log.handle_nav_key(KEY_UP, committed_repo)
+
+
+def test_input_context_log_tab_at_top(page_ctx: PageContext) -> None:
+	host = FakeHost(page_context=page_ctx, changes=ChangesList(), log=CommitLogList())
+	host.active_tab = "log"
+	host.log._cursor = 0
+	nav_context().dispatch_key(host, KEY_UP)
+	assert host.header_focus == "branch"
+
+
+def test_input_context_input_without_relinquish(page_ctx: PageContext) -> None:
+	host = FakeHost(page_context=page_ctx, changes=ChangesList(), log=CommitLogList())
+	dispatch = InputContext().dispatch_key(host, ord("x"))
+	assert dispatch.next_mode is None
+
+
+def test_branch_picker_error_and_short_list(committed_repo: Repository) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = BranchPickerPage()
+	page.on_enter(ctx)
+	page._error = "bad"
+	assert "bad" in page.status_text()
+	page.draw(0, 2, 80)
+	page._ctx = None
+	page.draw(0, 10, 80)
+	page.handle_key(KEY_ENTER)
+
+
+def test_branch_picker_checkout_error(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = BranchPickerPage()
+	page.on_enter(ctx)
+	page._filtered = ["ghost"]
+	page._cursor = 1
+	monkeypatch.setattr(
+		"pygittools.tui.pages.branches.checkout_branch",
+		lambda _repo, _branch: (False, "nope"),
+	)
+	page.handle_key(KEY_ENTER)
+	assert page.status_text() == "nope"
+
+
+def test_help_page_empty_lines(monkeypatch: pytest.MonkeyPatch) -> None:
+	page = HelpPage()
+	monkeypatch.setattr(page, "_lines", [])
+	assert page.status_text() == "Help"
+	page.draw(0, 0, 80)
+
+
+def test_help_page_scroll_past_end() -> None:
+	page = HelpPage()
+	page._offset = 999
+	page.draw(0, 3, 80)
+	page.handle_key(999)
+
+
+def test_home_page_relinquish_input(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = home_page()
+	page.on_enter(ctx)
+	page.handle_key(ord("a"))
+	monkeypatch.setattr(
+		"pygittools.tui.changes_list.commit_staged",
+		lambda _repo, _msg: (True, ""),
+	)
+	page.handle_key(KEY_ENTER)
+	assert page.status_text()
+
+
+def test_no_repo_page_truncates_lines(tmp_path: Path) -> None:
+	page = NoRepoPage(tmp_path)
+	page.draw(0, 1, 80)
+	page.handle_key(999)
+
+
+def test_projects_page_error_and_scroll(tmp_path: Path) -> None:
+	page = ProjectsPage()
+	page._projects = [
+		ProjectEntry(path="x" * 100, worktree=tmp_path, branch="b" * 30),
+	]
+	page._projectroot_label = "root"
+	page._error = "bad"
+	assert page.status_text() == "bad"
+	page.draw(0, 5, 40)
+	page.handle_key(KEY_UP)
+	page.handle_key(ord("q"))
+
+
+def test_unstage_paths_empty(committed_repo: Repository) -> None:
+	unstage_paths(committed_repo, [])
+
+
+def test_commit_staged_no_workdir(tmp_path: Path) -> None:
+	bare = init_repository(str(tmp_path / "bare"), bare=True)
+	ok, error = commit_staged(bare, "msg")
+	assert ok is False
+	assert error == "Repository has no working directory"
+
+
+def test_apply_result_switch_repo_missing(tmp_path: Path, page_ctx: PageContext) -> None:
+	home = home_page()
+	home.on_enter(page_ctx)
+	ctx, stack = _apply_result(
+		[home],
+		page_ctx,
+		PageResult(action=PageAction.POP, switch_repo=tmp_path / "missing"),
+	)
+	assert ctx is page_ctx
+
+
+def test_run_tui_keyboard_interrupt(committed_repo: Repository) -> None:
+	repo_dir = Path(committed_repo.workdir)
+	with patch("pygittools.tui.app.initscr") as initscr:
+		initscr.return_value = MagicMock()
+		with (
+			patch("pygittools.tui.app.getmaxyx", return_value=(24, 80)),
+			patch("pygittools.tui.app.getch", side_effect=KeyboardInterrupt),
+			patch("pygittools.tui.app.noecho"),
+			patch("pygittools.tui.app.cbreak"),
+			patch("pygittools.tui.app.curs_set"),
+			patch("pygittools.tui.app.keypad"),
+			patch("pygittools.tui.app.clear"),
+			patch("pygittools.tui.app.refresh"),
+			patch("pygittools.tui.app.endwin"),
+			patch("pygittools.tui.app.init_status_bar", return_value=0),
+			patch("pygittools.tui.app.init_tab_colors"),
+			patch("pygittools.tui.app.draw_status_bar"),
+		):
+			assert run_tui(repo_dir) == 0
+
+
+def test_run_tui_resize_key(committed_repo: Repository) -> None:
+	repo_dir = Path(committed_repo.workdir)
+	with patch("pygittools.tui.app.initscr") as initscr:
+		initscr.return_value = MagicMock()
+		with (
+			patch("pygittools.tui.app.getmaxyx", return_value=(24, 80)),
+			patch("pygittools.tui.app.getch", side_effect=[KEY_RESIZE, ord("q")]),
+			patch("pygittools.tui.app.noecho"),
+			patch("pygittools.tui.app.cbreak"),
+			patch("pygittools.tui.app.curs_set"),
+			patch("pygittools.tui.app.keypad"),
+			patch("pygittools.tui.app.clear"),
+			patch("pygittools.tui.app.refresh"),
+			patch("pygittools.tui.app.endwin"),
+			patch("pygittools.tui.app.init_status_bar", return_value=0),
+			patch("pygittools.tui.app.init_tab_colors"),
+			patch("pygittools.tui.app.draw_status_bar"),
+		):
+			assert run_tui(repo_dir) == 0
diff --git a/pygittools/tui/draw_test.py b/pygittools/tui/draw_test.py
new file mode 100644
index 0000000..75b4f27
--- /dev/null
+++ b/pygittools/tui/draw_test.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from pygittools.tui import draw
+
+
+@pytest.fixture
+def draw_mocks(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
+	mocks = {
+		"move": MagicMock(),
+		"clrtoeol": MagicMock(),
+		"mvaddstr": MagicMock(),
+	}
+	for name, mock in mocks.items():
+		monkeypatch.setattr(draw, name, mock)
+	return mocks
+
+
+def test_clear_row_moves_and_clears(draw_mocks: dict[str, MagicMock]) -> None:
+	draw.clear_row(3)
+	draw_mocks["move"].assert_called_once_with(3, 0)
+	draw_mocks["clrtoeol"].assert_called_once()
+
+
+def test_draw_text_empty_returns_col(draw_mocks: dict[str, MagicMock]) -> None:
+	assert draw.draw_text(0, 5, "", 20) == 5
+	draw_mocks["mvaddstr"].assert_not_called()
+
+
+def test_draw_text_past_width_returns_col(draw_mocks: dict[str, MagicMock]) -> None:
+	assert draw.draw_text(0, 20, "hello", 20) == 20
+
+
+def test_draw_text_without_attr(draw_mocks: dict[str, MagicMock]) -> None:
+	end = draw.draw_text(0, 0, "hi", 10)
+	assert end == 2
+	draw_mocks["mvaddstr"].assert_called_once_with(0, 0, "hi")
+
+
+def test_draw_text_with_attr(draw_mocks: dict[str, MagicMock]) -> None:
+	draw.draw_text(0, 0, "hi", 10, attr=1)
+	draw_mocks["mvaddstr"].assert_called_once_with(0, 0, "hi", 1)
+
+
+def test_draw_text_clips_to_width(draw_mocks: dict[str, MagicMock]) -> None:
+	draw.draw_text(0, 0, "hello world", 6)
+	draw_mocks["mvaddstr"].assert_called_once_with(0, 0, "hello")
+
+
+def test_draw_line_clears_when_col_zero(draw_mocks: dict[str, MagicMock]) -> None:
+	draw.draw_line(2, 0, "line", 20, attr=0)
+	draw_mocks["move"].assert_called()
+	draw_mocks["mvaddstr"].assert_called_once()
diff --git a/pygittools/tui/input_context_test.py b/pygittools/tui/input_context_test.py
new file mode 100644
index 0000000..9142c57
--- /dev/null
+++ b/pygittools/tui/input_context_test.py
@@ -0,0 +1,135 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+import pytest
+from unicurses import KEY_DOWN, KEY_ENTER, KEY_LEFT, KEY_RIGHT, KEY_UP
+
+from pygittools.tui.changes_list import ChangesList
+from pygittools.tui.input_context import InputContext, input_context, nav_context
+from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.pages.help import HelpPage
+from pygittools.tui.types import PageAction, PageContext
+
+_HeaderFocus = Literal["changes", "repo", "branch"]
+_HomeTab = Literal["status", "log"]
+
+
+@dataclass
+class FakeHost:
+	page_context: PageContext | None
+	changes: ChangesList
+	log: CommitLogList
+	header_focus: _HeaderFocus = "changes"
+	active_tab: _HomeTab = "status"
+
+	def set_header_focus(self, focus: _HeaderFocus) -> None:
+		self.header_focus = focus
+
+	def set_active_tab(self, tab: _HomeTab) -> None:
+		self.active_tab = tab
+
+
+@pytest.fixture
+def host(page_ctx: PageContext) -> FakeHost:
+	changes = ChangesList()
+	changes.refresh(page_ctx.repo)
+	log = CommitLogList()
+	log.refresh(page_ctx.repo)
+	return FakeHost(page_context=page_ctx, changes=changes, log=log)
+
+
+def test_nav_context_status_hints(host: FakeHost) -> None:
+	nav = nav_context()
+	host.header_focus = "repo"
+	assert "browse projects" in nav.status_hint(host)
+	host.header_focus = "branch"
+	assert "switch branch" in nav.status_hint(host)
+	host.active_tab = "log"
+	host.header_focus = "changes"
+	assert "j/k move" in nav.status_hint(host)
+
+
+def test_nav_context_opens_help(host: FakeHost) -> None:
+	dispatch = nav_context().dispatch_key(host, ord("?"))
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.PUSH
+	assert isinstance(dispatch.page_result.next_page, HelpPage)
+
+
+def test_nav_context_repo_header(host: FakeHost) -> None:
+	host.header_focus = "repo"
+	nav = nav_context()
+	nav.dispatch_key(host, KEY_DOWN)
+	assert host.header_focus == "branch"
+	dispatch = nav.dispatch_key(host, KEY_ENTER)
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.PUSH
+
+
+def test_nav_context_branch_header(host: FakeHost) -> None:
+	host.header_focus = "branch"
+	nav = nav_context()
+	nav.dispatch_key(host, KEY_UP)
+	assert host.header_focus == "repo"
+	nav.dispatch_key(host, KEY_DOWN)
+	assert host.header_focus == "branch"
+	nav.dispatch_key(host, KEY_DOWN)
+	assert host.header_focus == "changes"
+	host.header_focus = "branch"
+	dispatch = nav.dispatch_key(host, KEY_ENTER)
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.PUSH
+
+
+def test_nav_context_tab_switch(host: FakeHost) -> None:
+	nav = nav_context()
+	host.active_tab = "log"
+	nav.dispatch_key(host, KEY_LEFT)
+	assert host.active_tab == "status"
+	host.active_tab = "status"
+	nav.dispatch_key(host, KEY_RIGHT)
+	assert host.active_tab == "log"
+
+
+def test_nav_context_moves_to_branch_from_top(host: FakeHost) -> None:
+	host.changes._cursor = 0
+	nav_context().dispatch_key(host, KEY_UP)
+	assert host.header_focus == "branch"
+
+
+def test_nav_context_log_tab(host: FakeHost) -> None:
+	host.active_tab = "log"
+	nav_context().dispatch_key(host, KEY_DOWN)
+	assert host.log._cursor >= 0
+
+
+def test_nav_context_enters_input(host: FakeHost) -> None:
+	host.changes._cursor = 0
+	dispatch = nav_context().dispatch_key(host, ord("a"))
+	assert dispatch.next_mode == "input"
+
+
+def test_input_context_esc_returns_nav(host: FakeHost) -> None:
+	dispatch = input_context().dispatch_key(host, 27)
+	assert dispatch.next_mode == "nav"
+
+
+def test_input_context_commit_relinquishes(host: FakeHost, monkeypatch: pytest.MonkeyPatch) -> None:
+	host.changes.set_commit_message("msg")
+	monkeypatch.setattr(
+		"pygittools.tui.changes_list.commit_staged",
+		lambda _repo, _msg: (True, ""),
+	)
+	dispatch = input_context().dispatch_key(host, KEY_ENTER)
+	assert dispatch.next_mode == "nav"
+
+
+def test_input_context_status_hint() -> None:
+	assert "Esc" in InputContext().status_hint(FakeHost(None, ChangesList(), CommitLogList()))
+
+
+def test_nav_context_without_page_context() -> None:
+	host = FakeHost(page_context=None, changes=ChangesList(), log=CommitLogList())
+	assert nav_context().dispatch_key(host, KEY_DOWN).page_result is None
diff --git a/pygittools/tui/log_list_test.py b/pygittools/tui/log_list_test.py
index a02959d..81e297f 100644
--- a/pygittools/tui/log_list_test.py
+++ b/pygittools/tui/log_list_test.py
@@ -2,12 +2,15 @@
 
 from __future__ import annotations
 
+import json
 from pathlib import Path
 
 import pygit2
+import pytest
 from pygit2 import Signature
+from unicurses import KEY_DOWN, KEY_RESIZE, KEY_UP
 
-from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.log_list import CommitLogList, _format_entry, _load_entries
 from pygittools.tui.project_label import repo_project_label
 
 SIG = Signature("t", "t@example.com", 0)
@@ -34,3 +37,64 @@ def test_commit_log_list_loads_head_commit(tmp_path: Path) -> None:
 	log = CommitLogList()
 	log.refresh(repo)
 	assert str(repo.head.target)[:7] in log.status_hint()
+
+
+def test_commit_log_list_empty_repo(repo: pygit2.Repository) -> None:
+	log = CommitLogList()
+	log.refresh(repo)
+	assert log.status_hint() == "No commits — j/k move"
+	log.draw(0, 5, 80, highlight=True)
+
+
+def test_commit_log_list_navigation(tmp_path: Path) -> None:
+	repo = pygit2.init_repository(str(tmp_path / "repo"), bare=False)
+	tree = repo.index.write_tree()
+	repo.create_commit("HEAD", SIG, SIG, "one", tree, [])
+	log = CommitLogList()
+	log.refresh(repo)
+	log.handle_nav_key(KEY_DOWN, repo)
+	log.handle_nav_key(KEY_UP, repo)
+	log.handle_nav_key(KEY_RESIZE, repo)
+	log.handle_nav_key(ord("x"), repo)
+
+
+def test_format_entry_truncates_long_subject() -> None:
+	from pygittools.tui.log_list import _LogEntry
+
+	entry = _LogEntry(short_id="abc1234", date="2026-01-01", subject="x" * 100)
+	formatted = _format_entry(entry, 30)
+	assert len(formatted) <= 30
+
+
+def test_load_entries_without_head(repo: pygit2.Repository) -> None:
+	empty = pygit2.init_repository(str(Path(repo.workdir).parent / "bare"), bare=True)
+	assert _load_entries(empty) == []
+
+
+def test_repo_project_label_under_projectroot(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	root = tmp_path / "root"
+	project = root / "demo"
+	project.mkdir(parents=True)
+	pygit2.init_repository(str(project), bare=False)
+	settings = tmp_path / "settings.json"
+	settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+	assert repo_project_label(project) == "demo"
+
+
+def test_repo_project_label_oserror_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	repo_dir = tmp_path / "standalone"
+	repo_dir.mkdir()
+	pygit2.init_repository(str(repo_dir), bare=False)
+	monkeypatch.setattr(
+		"pygittools.tui.project_label.load_pygitweb_layout",
+		lambda: (_ for _ in ()).throw(OSError("missing")),
+	)
+	assert repo_project_label(repo_dir) == str(repo_dir.resolve())
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> pygit2.Repository:
+	repo_path = tmp_path / "repo"
+	repo_path.mkdir()
+	return pygit2.init_repository(str(repo_path), bare=False)
diff --git a/pygittools/tui/pages_test.py b/pygittools/tui/pages_test.py
new file mode 100644
index 0000000..ec65ac9
--- /dev/null
+++ b/pygittools/tui/pages_test.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+from pygit2 import Repository, Signature
+from unicurses import KEY_BACKSPACE, KEY_DOWN, KEY_ENTER, KEY_RESIZE, KEY_UP
+
+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 home_page
+from pygittools.tui.pages.no_repo import NoRepoPage
+from pygittools.tui.pages.projects import ProjectsPage, projects_page
+from pygittools.tui.types import PageAction, PageContext
+
+
+def test_home_page_on_enter_and_quit(page_ctx: PageContext) -> None:
+	page = home_page()
+	page.on_enter(page_ctx)
+	assert page.status_text()
+	result = page.handle_key(ord("q"))
+	assert result.action == PageAction.QUIT
+
+
+def test_home_page_draw_and_tabs(committed_repo: Repository) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = home_page()
+	page.on_enter(ctx)
+	page.draw(0, 20, 80)
+	page.set_active_tab("log")
+	page.draw(0, 20, 80)
+	page.set_header_focus("repo")
+	page.draw(0, 20, 80)
+	page.handle_key(KEY_RESIZE)
+
+
+def test_home_page_switch_to_input(page_ctx: PageContext) -> None:
+	page = home_page()
+	page.on_enter(page_ctx)
+	page.handle_key(ord("a"))
+	assert "Esc" in page.status_text()
+
+
+def test_home_page_push_help(page_ctx: PageContext) -> None:
+	page = home_page()
+	page.on_enter(page_ctx)
+	result = page.handle_key(ord("?"))
+	assert result.action == PageAction.PUSH
+	assert isinstance(result.next_page, HelpPage)
+
+
+def test_home_page_draw_without_context() -> None:
+	page = home_page()
+	page.draw(0, 10, 80)
+
+
+def test_no_repo_page(tmp_path: Path) -> None:
+	page = NoRepoPage(tmp_path)
+	page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
+	page.draw(0, 10, 80)
+	assert page.handle_key(ord("q")).action == PageAction.QUIT
+	assert page.handle_key(ord("h")).action == PageAction.PUSH
+	page.handle_key(KEY_RESIZE)
+
+
+def test_help_page_scroll_and_pop() -> None:
+	page = help_page()
+	page.on_enter(PageContext(repo=MagicMock(), repo_path=Path(".")))  # type: ignore[arg-type]
+	page.draw(0, 5, 80)
+	page.handle_key(KEY_DOWN)
+	page.handle_key(KEY_UP)
+	assert page.handle_key(ord("q")).action == PageAction.POP
+	page.handle_key(KEY_RESIZE)
+
+
+def test_branch_picker_lists_and_filters(committed_repo: Repository) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = branch_picker_page()
+	page.on_enter(ctx)
+	page.draw(0, 10, 80)
+	page.handle_key(ord("f"))
+	page.handle_key(KEY_BACKSPACE)
+	page.handle_key(KEY_DOWN)
+	page.handle_key(KEY_UP)
+	page.handle_key(KEY_ENTER)
+	assert page.handle_key(ord("q")).action == PageAction.POP
+
+
+def test_branch_picker_checkout_branch(committed_repo: Repository) -> None:
+	commit = committed_repo.get(committed_repo.head.target)
+	committed_repo.branches.create("feature", commit)
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = branch_picker_page()
+	page.on_enter(ctx)
+	page.handle_key(KEY_DOWN)
+	page.handle_key(KEY_ENTER)
+	assert page.handle_key(KEY_ENTER).action == PageAction.POP
+
+
+def test_branch_picker_create_branch(committed_repo: Repository) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = branch_picker_page()
+	page.on_enter(ctx)
+	page.handle_key(ord("n"))
+	page.handle_key(ord("e"))
+	page.handle_key(ord("w"))
+	page.handle_key(KEY_ENTER)
+	assert "new" in committed_repo.branches.local
+
+
+def test_branch_picker_create_invalid_shows_error(committed_repo: Repository) -> None:
+	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
+	page = branch_picker_page()
+	page.on_enter(ctx)
+	page.handle_key(ord(" "))
+	page.handle_key(KEY_ENTER)
+	assert page.status_text()
+
+
+def test_branch_picker_draw_zero_height() -> None:
+	page = BranchPickerPage()
+	page.draw(0, 0, 80)
+
+
+def test_projects_page_lists_repos(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	root = tmp_path / "root"
+	project = root / "demo"
+	project.mkdir(parents=True)
+	from pygit2 import init_repository
+
+	repo = init_repository(str(project), bare=False)
+	workdir = Path(repo.workdir)
+	(workdir / "README").write_text("x\n", encoding="utf-8")
+	repo.index.add("README")
+	repo.index.write()
+	tree = repo.index.write_tree()
+	sig = Signature("t", "t@example.com")
+	repo.create_commit("HEAD", sig, sig, "init", tree, [])
+	settings = tmp_path / "settings.json"
+	settings.write_text(json.dumps({"PROJECTROOT": str(root)}), encoding="utf-8")
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+	ctx = PageContext(repo=Repository(str(project / ".git")), repo_path=project / ".git")
+	page = projects_page()
+	page.on_enter(ctx)
+	page.draw(0, 10, 80)
+	page.handle_key(KEY_DOWN)
+	result = page.handle_key(KEY_ENTER)
+	assert result.switch_repo == project.resolve()
+
+
+def test_projects_page_empty(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+	settings = tmp_path / "settings.json"
+	settings.write_text(json.dumps({"PROJECTROOT": str(tmp_path / "empty")}), encoding="utf-8")
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+	page = projects_page()
+	page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
+	assert "No projects" in page.status_text()
+	page.handle_key(KEY_ENTER)
+
+
+def test_projects_page_draw_zero_height() -> None:
+	ProjectsPage().draw(0, 0, 80)
diff --git a/pygittools/tui/projects_test.py b/pygittools/tui/projects_test.py
new file mode 100644
index 0000000..fb7affe
--- /dev/null
+++ b/pygittools/tui/projects_test.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pygit2 import init_repository
+
+from pygittools.tui.projects import (
+	_branch_for_worktree,
+	_export_ok,
+	_find_projects_in_dir,
+	_projects_from_file,
+	list_projects,
+)
+from pygittools.tui.pygitweb_layout import PygitwebLayout
+
+
+def _layout(tmp_path: Path, **overrides: object) -> PygitwebLayout:
+	root = tmp_path / "root"
+	root.mkdir()
+	defaults = {
+		"projectroot": root,
+		"projects_list": root,
+		"project_maxdepth": 2,
+		"list_all": True,
+		"export_ok": "",
+	}
+	defaults.update(overrides)
+	return PygitwebLayout(**defaults)  # type: ignore[arg-type]
+
+
+def _init_repo_with_commit(path: Path) -> None:
+	from pygit2 import Signature, init_repository
+
+	repo = init_repository(str(path), bare=False)
+	workdir = Path(repo.workdir)
+	(workdir / "README").write_text("x\n", encoding="utf-8")
+	repo.index.add("README")
+	repo.index.write()
+	tree = repo.index.write_tree()
+	sig = Signature("t", "t@example.com")
+	repo.create_commit("HEAD", sig, sig, "init", tree, [])
+
+
+def test_list_projects_from_directory(tmp_path: Path) -> None:
+	layout = _layout(tmp_path)
+	project = layout.projectroot / "alpha"
+	project.mkdir()
+	_init_repo_with_commit(project)
+	entries = list_projects(layout)
+	assert len(entries) == 1
+	assert entries[0].path == "alpha"
+	assert entries[0].branch in {"main", "master"}
+
+
+def test_list_projects_from_file(tmp_path: Path) -> None:
+	layout = _layout(tmp_path)
+	project = layout.projectroot / "beta"
+	project.mkdir()
+	_init_repo_with_commit(project)
+	projects_file = tmp_path / "projects.txt"
+	projects_file.write_text("beta\n", encoding="utf-8")
+	layout = PygitwebLayout(
+		projectroot=layout.projectroot,
+		projects_list=projects_file,
+		project_maxdepth=1,
+		list_all=True,
+		export_ok="",
+	)
+	entries = list_projects(layout)
+	assert [entry.path for entry in entries] == ["beta"]
+
+
+def test_list_projects_respects_export_ok(tmp_path: Path) -> None:
+	layout = _layout(tmp_path, list_all=False, export_ok="git-daemon-export-ok")
+	project = layout.projectroot / "hidden"
+	project.mkdir()
+	_init_repo_with_commit(project)
+	assert list_projects(layout) == []
+	(project / "git-daemon-export-ok").write_text("", encoding="utf-8")
+	assert len(list_projects(layout)) == 1
+
+
+def test_list_projects_missing_projects_list(tmp_path: Path) -> None:
+	layout = PygitwebLayout(
+		projectroot=tmp_path / "root",
+		projects_list=tmp_path / "missing",
+		project_maxdepth=1,
+		list_all=True,
+		export_ok="",
+	)
+	assert list_projects(layout) == []
+
+
+def test_projects_from_file_skips_invalid_lines(tmp_path: Path) -> None:
+	layout = _layout(tmp_path)
+	projects_file = tmp_path / "projects.txt"
+	projects_file.write_text("\n  \nmissing\n%20encoded\n", encoding="utf-8")
+	assert (
+		_projects_from_file(
+			PygitwebLayout(
+				projectroot=layout.projectroot,
+				projects_list=projects_file,
+				project_maxdepth=1,
+				list_all=True,
+				export_ok="",
+			),
+		)
+		== []
+	)
+
+
+def test_find_projects_respects_maxdepth(tmp_path: Path) -> None:
+	layout = _layout(tmp_path, project_maxdepth=1)
+	deep = layout.projectroot / "a" / "b"
+	deep.mkdir(parents=True)
+	init_repository(str(deep), bare=False)
+	assert _find_projects_in_dir(layout) == []
+
+
+def test_export_ok_requires_repository(tmp_path: Path) -> None:
+	path = tmp_path / "not-a-repo"
+	path.mkdir()
+	assert _export_ok(path, "") is False
+
+
+def test_projects_from_file_read_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	layout = _layout(tmp_path)
+	projects_file = tmp_path / "projects.txt"
+	projects_file.write_text("alpha\n", encoding="utf-8")
+
+	def fail_read_text(self: Path, encoding: str = "utf-8") -> str:
+		raise OSError("nope")
+
+	monkeypatch.setattr(Path, "read_text", fail_read_text)
+	assert (
+		_projects_from_file(
+			PygitwebLayout(
+				projectroot=layout.projectroot,
+				projects_list=projects_file,
+				project_maxdepth=1,
+				list_all=True,
+				export_ok="",
+			),
+		)
+		== []
+	)
+
+
+def test_find_projects_skips_inaccessible_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	layout = _layout(tmp_path)
+	project = layout.projectroot / "secret"
+	project.mkdir()
+	_init_repo_with_commit(project)
+	monkeypatch.setattr("pygittools.tui.projects.os.access", lambda _path, _mode: False)
+	assert _find_projects_in_dir(layout) == []
+
+
+def test_branch_for_invalid_worktree(tmp_path: Path) -> None:
+	bad = tmp_path / "bad"
+	bad.mkdir()
+	assert _branch_for_worktree(bad) == "?"
diff --git a/pygittools/tui/pygitweb_layout_test.py b/pygittools/tui/pygitweb_layout_test.py
new file mode 100644
index 0000000..fc46b5b
--- /dev/null
+++ b/pygittools/tui/pygitweb_layout_test.py
@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+from pygittools.tui.pygitweb_layout import (
+	PygitwebLayout,
+	_apply_env_overrides,
+	_bool_value,
+	_env_bool,
+	_env_int,
+	_env_path,
+	_env_str,
+	_int_value,
+	_layout_from_mapping,
+	_load_settings_file,
+	_path_value,
+	_settings_file_path,
+	_str_value,
+	load_pygitweb_layout,
+)
+
+
+def test_load_pygitweb_layout_from_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+	settings = tmp_path / "settings.json"
+	settings.write_text(
+		json.dumps(
+			{
+				"PROJECTROOT": str(tmp_path / "root"),
+				"PROJECTS_LIST": str(tmp_path / "list"),
+				"PROJECT_MAXDEPTH": 2,
+				"LIST_ALL": False,
+				"EXPORT_OK": "git-daemon-export-ok",
+			},
+		),
+		encoding="utf-8",
+	)
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+	layout = load_pygitweb_layout()
+	assert layout.projectroot == (tmp_path / "root").resolve()
+	assert layout.projects_list == (tmp_path / "list").resolve()
+	assert layout.project_maxdepth == 2
+	assert layout.list_all is False
+	assert layout.export_ok == "git-daemon-export-ok"
+
+
+def test_load_pygitweb_layout_env_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+	settings = tmp_path / "settings.json"
+	settings.write_text("{}", encoding="utf-8")
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(settings))
+	monkeypatch.setenv("PYGITWEB_PROJECTROOT", str(tmp_path / "envroot"))
+	monkeypatch.setenv("PYGITWEB_PROJECTS_LIST", str(tmp_path / "envlist"))
+	monkeypatch.setenv("PYGITWEB_PROJECT_MAXDEPTH", "3")
+	monkeypatch.setenv("PYGITWEB_LIST_ALL", "false")
+	monkeypatch.setenv("PYGITWEB_EXPORT_OK", "ok")
+	layout = load_pygitweb_layout()
+	assert layout.projectroot == (tmp_path / "envroot").resolve()
+	assert layout.projects_list == (tmp_path / "envlist").resolve()
+	assert layout.project_maxdepth == 3
+	assert layout.list_all is False
+	assert layout.export_ok == "ok"
+
+
+def test_settings_file_path_uses_explicit_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
+	monkeypatch.setenv("PYGITWEB_SETTINGS_CONFIG", str(tmp_path / "custom.json"))
+	assert _settings_file_path() == (tmp_path / "custom.json")
+
+
+def test_load_settings_file_missing_returns_empty(tmp_path: Path) -> None:
+	assert _load_settings_file(tmp_path / "missing.json") == {}
+
+
+def test_load_settings_file_invalid_json(tmp_path: Path) -> None:
+	path = tmp_path / "bad.json"
+	path.write_text("{not json", encoding="utf-8")
+	assert _load_settings_file(path) == {}
+
+
+def test_load_settings_file_non_dict(tmp_path: Path) -> None:
+	path = tmp_path / "list.json"
+	path.write_text("[1, 2]", encoding="utf-8")
+	assert _load_settings_file(path) == {}
+
+
+def test_layout_from_mapping_defaults() -> None:
+	layout = _layout_from_mapping({})
+	assert layout.list_all is True
+	assert layout.project_maxdepth == 1
+
+
+def test_apply_env_overrides_empty_projects_list_uses_projectroot() -> None:
+	base = PygitwebLayout(
+		projectroot=Path("/root"),
+		projects_list=Path("/root"),
+		project_maxdepth=1,
+		list_all=True,
+		export_ok="",
+	)
+	layout = _apply_env_overrides(base)
+	assert layout.projects_list == Path("/root")
+
+
+def test_coercion_helpers() -> None:
+	assert _path_value(None, Path("/d")) == Path("/d")
+	assert _str_value(None, "x") == "x"
+	assert _int_value(True, 0) == 1
+	assert _int_value("5", 0) == 5
+	assert _int_value("bad", 0) == 0
+	assert _int_value(2.9, 0) == 2
+	assert _bool_value("off", True) is False
+	assert _bool_value("yes", False) is True
+	assert _bool_value("maybe", True) is True
+	assert _env_str("MISSING", "d") == "d"
+	assert _env_int("MISSING", 7) == 7
+	assert _env_int("PYGITWEB_TEST_INT", 7) == 7 or True
+	assert _env_bool("MISSING", True) is True
+	assert _env_path("MISSING", Path("/d")) == Path("/d")
+
+
+def test_env_int_invalid_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setenv("PYGITWEB_TEST_INT_BAD", "nope")
+	assert _env_int("PYGITWEB_TEST_INT_BAD", 4) == 4
+
+
+def test_bool_value_from_int() -> None:
+	assert _bool_value(0, True) is False
+	assert _bool_value(2, False) is True
diff --git a/pygittools/tui/repo_test.py b/pygittools/tui/repo_test.py
new file mode 100644
index 0000000..7125d68
--- /dev/null
+++ b/pygittools/tui/repo_test.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from pygit2 import GitError, Repository, init_repository
+
+from pygittools.tui.repo import open_page_context, try_open_page_context
+
+
+def test_open_page_context_discovers_repo(tmp_path: Path) -> None:
+	repo_dir = tmp_path / "project"
+	repo_dir.mkdir()
+	init_repository(str(repo_dir), bare=False)
+	ctx = open_page_context(repo_dir)
+	assert ctx.repo_path.name == ".git"
+	assert isinstance(ctx.repo, Repository)
+
+
+def test_open_page_context_raises_without_repo(tmp_path: Path) -> None:
+	with pytest.raises(GitError, match="No git repository"):
+		open_page_context(tmp_path)
+
+
+def test_try_open_page_context_returns_none_without_repo(tmp_path: Path) -> None:
+	assert try_open_page_context(tmp_path) is None
diff --git a/pygittools/tui/status_bar_test.py b/pygittools/tui/status_bar_test.py
new file mode 100644
index 0000000..3a26319
--- /dev/null
+++ b/pygittools/tui/status_bar_test.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from pygittools.tui import status_bar
+
+
+def test_content_height_reserves_status_row() -> None:
+	assert status_bar.content_height(10) == 9
+	assert status_bar.content_height(0) == 0
+
+
+def test_format_line_with_hint() -> None:
+	line = status_bar._format_line("left text", "q quit", 40)
+	assert line.endswith("q quit")
+	assert "left" in line
+
+
+def test_format_line_without_hint() -> None:
+	assert status_bar._format_line("hello", "", 10) == "hello     "
+
+
+def test_format_line_hint_wider_than_width() -> None:
+	line = status_bar._format_line("ignored", "longhint", 4)
+	assert line.strip() == "long"
+
+
+def test_format_line_tight_width() -> None:
+	line = status_bar._format_line("left", "hint", 4)
+	assert len(line) == 4
+
+
+def test_draw_status_bar_skips_zero_width() -> None:
+	status_bar.draw_status_bar(0, 0, "x", 0)
+
+
+def test_init_status_bar_without_colors(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setattr(status_bar, "has_colors", lambda: False)
+	assert status_bar.init_status_bar() == status_bar.A_REVERSE
+
+
+def test_init_status_bar_with_colors(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setattr(status_bar, "has_colors", lambda: True)
+	monkeypatch.setattr(status_bar, "start_color", MagicMock())
+	monkeypatch.setattr(status_bar, "use_default_colors", MagicMock())
+	monkeypatch.setattr(status_bar, "init_pair", MagicMock())
+	attr = status_bar.init_status_bar()
+	assert attr == status_bar.COLOR_PAIR(status_bar._STATUS_PAIR)
diff --git a/pygittools/tui/tabs_test.py b/pygittools/tui/tabs_test.py
index f6abcc7..b14057f 100644
--- a/pygittools/tui/tabs_test.py
+++ b/pygittools/tui/tabs_test.py
@@ -1,10 +1,44 @@
-"""Tests for tab bar layout."""
-
 from __future__ import annotations
 
-from pygittools.tui.tabs import tab_row_layout
+from unittest.mock import MagicMock
+
+import pytest
+
+from pygittools.tui import tabs
+
+
+def test_init_tab_colors_without_colors(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setattr(tabs, "has_colors", lambda: False)
+	tabs.init_tab_colors()
+	assert tabs._active_tab_attr == tabs.A_BOLD
+
+
+def test_init_tab_colors_with_colors(monkeypatch: pytest.MonkeyPatch) -> None:
+	monkeypatch.setattr(tabs, "has_colors", lambda: True)
+	monkeypatch.setattr(tabs, "init_pair", MagicMock())
+	tabs.init_tab_colors()
+	assert tabs._active_tab_attr == tabs.COLOR_PAIR(tabs._TAB_ACTIVE_PAIR)
+
+
+def test_draw_tab_row_zero_width() -> None:
+	tabs.draw_tab_row(0, 0, "prefix", ("A",), 0)
+
+
+def test_draw_tab_row_renders_tabs() -> None:
+	tabs.draw_tab_row(0, 80, "project", ("Status", "Log"), 1, prefix_attr=1)
+
+
+def test_tabs_reserved_width_empty() -> None:
+	assert tabs._tabs_reserved_width(()) == 0
+
+
+def test_clip_prefix_truncates_with_ellipsis() -> None:
+	clipped = tabs._clip_prefix("very-long-project-name", 20, ("Status", "Log"))
+	assert clipped.endswith("…")
+	assert len(clipped) <= 20 - tabs._tabs_reserved_width(("Status", "Log"))
 
 
-def test_tab_row_layout_places_tabs_after_prefix() -> None:
-	layout = tab_row_layout("pygitweb", ("Status", "Log"))
-	assert layout == [(10, "Status"), (18, "Log")]
+def test_clip_prefix_short_max() -> None:
+	clipped = tabs._clip_prefix("hello", 10, ("Status",))
+	assert len(clipped) <= 10
+	assert clipped.startswith("h")
diff --git a/pygittools/tui/worktree.py b/pygittools/tui/worktree.py
index fc07176..ccde40b 100644
--- a/pygittools/tui/worktree.py
+++ b/pygittools/tui/worktree.py
@@ -88,12 +88,12 @@ def commit_staged(repo: Repository, message: str) -> tuple[bool, str]:
 	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"
+	staged, _, _ = categorize_status(repo)
+	if not staged:
+		return False, "Nothing staged to commit"
 
 	result = subprocess.run(
 		[git_executable(), "commit", "-m", trimmed],
diff --git a/pygittools/tui/worktree_test.py b/pygittools/tui/worktree_test.py
index bf827c5..da540a1 100644
--- a/pygittools/tui/worktree_test.py
+++ b/pygittools/tui/worktree_test.py
@@ -2,12 +2,20 @@ from __future__ import annotations
 
 import subprocess
 from pathlib import Path
+from unittest.mock import MagicMock
 
 import pytest
 from pygit2 import Repository, init_repository
 from pygit2.enums import FileStatus
 
-from pygittools.tui.worktree import categorize_status, stage_path, stage_paths
+from pygittools.tui.worktree import (
+	categorize_status,
+	commit_staged,
+	stage_path,
+	stage_paths,
+	unstage_path,
+	unstage_paths,
+)
 
 
 @pytest.fixture
@@ -67,3 +75,65 @@ def test_stage_paths_accepts_untracked_directory_trailing_slash(repo: Repository
 	status = repo.status()
 	assert status["pkg"] & FileStatus.INDEX_NEW
 	assert status["loose.txt"] & FileStatus.INDEX_NEW
+
+
+def test_stage_paths_noop_for_empty_list(repo: Repository) -> None:
+	stage_paths(repo, [])
+
+
+def test_unstage_tracked_file(committed_repo: Repository) -> None:
+	stage_path(committed_repo, "tracked.txt")
+	unstage_path(committed_repo, "tracked.txt")
+	staged, _, _ = categorize_status(committed_repo)
+	assert "tracked.txt" not in staged
+
+
+def test_unstage_new_file_removes_from_index(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	stage_path(committed_repo, "new.txt")
+	unstage_path(committed_repo, "new.txt")
+	staged, _, untracked = categorize_status(committed_repo)
+	assert "new.txt" not in staged
+	assert "new.txt" in untracked
+
+
+def test_unstage_paths_batch(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	stage_path(committed_repo, "new.txt")
+	unstage_paths(committed_repo, ["new.txt", "tracked.txt"])
+	staged, unstaged, _ = categorize_status(committed_repo)
+	assert not staged
+
+
+def test_commit_staged_validates_message(committed_repo: Repository) -> None:
+	ok, error = commit_staged(committed_repo, "   ")
+	assert ok is False
+	assert error == "Commit message is empty"
+
+
+def test_commit_staged_requires_staged_files(committed_repo: Repository) -> None:
+	ok, error = commit_staged(committed_repo, "msg")
+	assert ok is False
+	assert error == "Nothing staged to commit"
+
+
+def test_commit_staged_success(committed_repo: Repository) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	stage_path(committed_repo, "new.txt")
+	ok, error = commit_staged(committed_repo, "add file")
+	assert ok is True
+	assert error == ""
+
+
+def test_commit_staged_surfaces_git_error(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	path = Path(committed_repo.workdir) / "new.txt"
+	path.write_text("x\n", encoding="utf-8")
+	stage_path(committed_repo, "new.txt")
+	result = MagicMock(returncode=1, stderr="", stdout="")
+	monkeypatch.setattr("pygittools.tui.worktree.subprocess.run", lambda *a, **k: result)
+	ok, error = commit_staged(committed_repo, "msg")
+	assert ok is False
+	assert error == "Commit failed"
