diff --git a/pygittools/help.md b/pygittools/help.md
index 516804a..721a94a 100644
--- a/pygittools/help.md
+++ b/pygittools/help.md
@@ -4,15 +4,19 @@ 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)
+The top line shows the project name followed by **Status** and **Log** tabs. The active tab is highlighted in yellow.
+
+- **H** / **Left** or **L** / **Right** — switch between **Status** and **Log** (anywhere except while typing a commit message)
+- **J** / **Up** — move selection up (from the changes or log list, moves to the branch row, then the project row)
 - **K** / **Down** — move selection down
-- **Repository row** — **Enter** opens the projects table (PyGitWeb `PROJECTROOT`)
+- **Project 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
+- **Commit row** — type a message, then **Enter** to commit staged changes (runs git hooks); **Enter** with an empty message starts typing
+- **Space** — collapse or expand a section header
 - **Enter** on a section header — stage or unstage every file in that section (on **Staged**, Enter stages all unstaged and untracked files when nothing is staged)
 - **Enter** on a file — stage (unstaged / untracked) or unstage (staged)
-- **H** — open this help page
+- **Log tab** — **J** / **K** scroll the commit history (newest first)
+- **?** — open this help page
 - **Q** — quit
 
 Commit or hook failures appear in the status bar.
diff --git a/pygittools/tui/app.py b/pygittools/tui/app.py
index 976dd10..f74b2a5 100644
--- a/pygittools/tui/app.py
+++ b/pygittools/tui/app.py
@@ -22,6 +22,7 @@ from pygittools.tui.pages.home import HomePage, 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.tabs import init_tab_colors
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
 
 
@@ -38,6 +39,7 @@ def run_tui(repo_path: Path | None = None) -> int:
 		curs_set(0)
 		keypad(stdscr, True)
 		status_attr = init_status_bar()
+		init_tab_colors()
 
 		if ctx is not None:
 			stack[0].on_enter(ctx)
diff --git a/pygittools/tui/changes_list.py b/pygittools/tui/changes_list.py
index 69ea9e3..2ae84a9 100644
--- a/pygittools/tui/changes_list.py
+++ b/pygittools/tui/changes_list.py
@@ -9,6 +9,7 @@ from typing import Literal
 
 from pygit2 import Repository
 from unicurses import (  # type: ignore[import-untyped]
+	A_BOLD,
 	A_REVERSE,
 	KEY_BACKSPACE,
 	KEY_DOWN,
@@ -20,7 +21,7 @@ from unicurses import (  # type: ignore[import-untyped]
 )
 
 from pygittools.tui.commit_draft import save_commit_draft
-from pygittools.tui.draw import draw_line
+from pygittools.tui.draw import clear_row, draw_line, draw_text
 from pygittools.tui.worktree import (
 	categorize_status,
 	commit_staged,
@@ -30,7 +31,9 @@ from pygittools.tui.worktree import (
 	unstage_paths,
 )
 
-_COMMIT_PREFIX = "Commit: "
+_COMMIT_LABEL = "Commit"
+_COMMIT_SUFFIX = ": "
+_COMMIT_PREFIX = f"{_COMMIT_LABEL}{_COMMIT_SUFFIX}"
 
 
 class SectionId(Enum):
@@ -122,7 +125,15 @@ class ChangesList:
 		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:
+	def draw(
+		self,
+		top_row: int,
+		height: int,
+		width: int,
+		*,
+		highlight: bool = True,
+		commit_input: bool = False,
+	) -> None:
 		if height <= 0:
 			return
 		self._ensure_cursor_visible(height)
@@ -132,9 +143,17 @@ class ChangesList:
 				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
+			screen_row = top_row + view_row
+			if row.kind == "commit":
+				self._draw_commit_row(
+					screen_row,
+					width,
+					(focused and highlight) or commit_input,
+					commit_input,
+				)
+				continue
 			attr = A_REVERSE if focused and highlight else 0
-			draw_line(top_row + view_row, 0, label, width, attr)
+			draw_line(screen_row, 0, row.label, width, attr)
 
 	def handle_nav_key(self, key: int, repo: Repository) -> NavKeyResult:
 		"""Handle navigation keys. May begin commit input when typing on the commit row."""
@@ -154,6 +173,8 @@ class ChangesList:
 
 		row = self._rows[self._cursor]
 		if row.kind == "commit":
+			if key in (KEY_ENTER, 10, 13) and not self._commit_message.strip():
+				return NavKeyResult(enter_input=True)
 			if self._try_begin_input(key):
 				return NavKeyResult(enter_input=True)
 			layout_changed = self._handle_commit_nav_key(key, repo)
@@ -205,6 +226,8 @@ class ChangesList:
 			return "Working tree clean"
 		row = self._rows[self._cursor]
 		if row.kind == "commit":
+			if not self._commit_message.strip():
+				return "Enter type message — j/k move"
 			return "Type message — Enter commit — j/k move"
 		if row.kind == "header":
 			if row.section == SectionId.STAGED:
@@ -239,11 +262,28 @@ class ChangesList:
 			self._error = error
 		return False
 
-	def _commit_label(self, width: int, focused: bool) -> str:
-		max_message = max(0, width - len(_COMMIT_PREFIX) - (1 if focused else 0))
+	def _draw_commit_row(self, row: int, width: int, focused: bool, commit_input: bool) -> None:
+		clear_row(row)
+		row_attr = A_REVERSE if focused else 0
+		show_cursor = focused
+		message = self._commit_message_text(width, show_cursor)
+		col = 0
+		label_attr = row_attr | A_BOLD if commit_input else row_attr
+		col = draw_text(row, col, _COMMIT_LABEL, width, label_attr)
+		col = draw_text(row, col, _COMMIT_SUFFIX, width, row_attr)
+		col = draw_text(row, col, message, width, row_attr)
+		if show_cursor:
+			draw_text(row, col, "_", width, row_attr)
+
+	def _commit_message_text(self, width: int, show_cursor: bool) -> str:
+		max_message = max(0, width - len(_COMMIT_PREFIX) - (1 if show_cursor else 0))
 		message = self._commit_message
 		if len(message) > max_message:
 			message = message[-max_message:]
+		return message
+
+	def _commit_label(self, width: int, focused: bool) -> str:
+		message = self._commit_message_text(width, focused)
 		label = f"{_COMMIT_PREFIX}{message}"
 		if focused:
 			label += "_"
diff --git a/pygittools/tui/draw.py b/pygittools/tui/draw.py
index c33e470..ed86589 100644
--- a/pygittools/tui/draw.py
+++ b/pygittools/tui/draw.py
@@ -5,12 +5,29 @@ 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."""
+def clear_row(row: int) -> None:
+	"""Clear a screen row before multi-segment redraws."""
+	move(row, 0)
+	clrtoeol()
+
+
+def draw_text(row: int, col: int, text: str, width: int, attr: int = 0) -> int:
+	"""Write ``text`` at ``col`` without clearing the rest of the row."""
+	if col >= width or not text:
+		return col
 	clipped = text[: max(0, width - col - 1)]
+	if not clipped:
+		return col
 	move(row, col)
-	clrtoeol()
 	if attr:
 		mvaddstr(row, col, clipped, attr)
 	else:
 		mvaddstr(row, col, clipped)
+	return col + len(clipped)
+
+
+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."""
+	if col == 0:
+		clear_row(row)
+	draw_text(row, col, text, width, attr)
diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
index 8818d6b..375a40f 100644
--- a/pygittools/tui/input_context.py
+++ b/pygittools/tui/input_context.py
@@ -8,10 +8,13 @@ from typing import Literal, Protocol
 from unicurses import (  # type: ignore[import-untyped]
 	KEY_DOWN,
 	KEY_ENTER,
+	KEY_LEFT,
+	KEY_RIGHT,
 	KEY_UP,
 )
 
 from pygittools.tui.changes_list import ChangesList
+from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.branches import branch_picker_page
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.pages.projects import projects_page
@@ -19,6 +22,7 @@ from pygittools.tui.types import PageAction, PageContext, PageResult
 
 _KEY_ESC = 27
 _HeaderFocus = Literal["changes", "repo", "branch"]
+_HomeTab = Literal["status", "log"]
 
 
 @dataclass(frozen=True, slots=True)
@@ -39,17 +43,30 @@ class PageInputHost(Protocol):
 	@property
 	def changes(self) -> ChangesList: ...
 
+	@property
+	def log(self) -> CommitLogList: ...
+
+	@property
+	def active_tab(self) -> _HomeTab: ...
+
+	def set_active_tab(self, tab: _HomeTab) -> None: ...
+
 
 class NavContext:
 	def status_hint(self, host: PageInputHost) -> str:
 		if host.header_focus == "repo":
-			return "Enter browse projects — j/k move"
+			return "Enter browse projects — j/k move — H/L switch tab"
 		if host.header_focus == "branch":
-			return "Enter switch branch — j/k move"
+			return "Enter switch branch — j/k move — H/L switch tab"
+		if host.active_tab == "log":
+			return host.log.status_hint()
 		return host.changes.status_hint()
 
 	def dispatch_key(self, host: PageInputHost, key: int) -> ContextDispatch:
-		if key in (ord("h"), ord("H")):
+		if _try_switch_tab(host, key):
+			return ContextDispatch()
+
+		if key == ord("?"):
 			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=help_page()))
 
 		if host.header_focus == "repo":
@@ -68,13 +85,16 @@ class NavContext:
 				return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()))
 			return ContextDispatch()
 
-		if key in (KEY_UP, ord("k"), ord("K")) and host.changes.is_at_top():
+		if key in (KEY_UP, ord("k"), ord("K")) and _content_list_is_at_top(host):
 			host.set_header_focus("branch")
 			return ContextDispatch()
 
 		ctx = host.page_context
 		if ctx is None:
 			return ContextDispatch()
+		if host.active_tab == "log":
+			host.log.handle_nav_key(key, ctx.repo)
+			return ContextDispatch()
 		nav_result = host.changes.handle_nav_key(key, ctx.repo)
 		if nav_result.enter_input:
 			return ContextDispatch(next_mode="input")
@@ -106,3 +126,21 @@ def nav_context() -> NavContext:
 
 def input_context() -> InputContext:
 	return InputContext()
+
+
+def _content_list_is_at_top(host: PageInputHost) -> bool:
+	if host.active_tab == "log":
+		return host.log.is_at_top()
+	return host.changes.is_at_top()
+
+
+def _try_switch_tab(host: PageInputHost, key: int) -> bool:
+	if key in (KEY_LEFT, ord("h"), ord("H")):
+		if host.active_tab == "log":
+			host.set_active_tab("status")
+		return True
+	if key in (KEY_RIGHT, ord("l"), ord("L")):
+		if host.active_tab == "status":
+			host.set_active_tab("log")
+		return True
+	return False
diff --git a/pygittools/tui/log_list.py b/pygittools/tui/log_list.py
new file mode 100644
index 0000000..e3184b4
--- /dev/null
+++ b/pygittools/tui/log_list.py
@@ -0,0 +1,121 @@
+"""Scrollable commit log for the pgt TUI."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+
+from pygit2 import GIT_SORT_TIME, Commit, GitError, Repository
+from unicurses import (  # type: ignore[import-untyped]
+	A_REVERSE,
+	KEY_DOWN,
+	KEY_RESIZE,
+	KEY_UP,
+)
+
+from pygittools.tui.draw import draw_line
+
+_MAX_COMMITS = 500
+
+
+@dataclass(frozen=True, slots=True)
+class _LogEntry:
+	short_id: str
+	date: str
+	subject: str
+
+
+@dataclass(frozen=True, slots=True)
+class LogNavKeyResult:
+	layout_changed: bool = False
+
+
+class CommitLogList:
+	def __init__(self) -> None:
+		self._entries: list[_LogEntry] = []
+		self._cursor = 0
+		self._scroll_offset = 0
+
+	def is_at_top(self) -> bool:
+		return self._cursor == 0
+
+	def refresh(self, repo: Repository) -> None:
+		self._entries = _load_entries(repo)
+		max_index = max(0, len(self._entries) - 1)
+		self._cursor = min(self._cursor, max_index)
+		self._scroll_offset = min(self._scroll_offset, max(0, len(self._entries) - 1))
+
+	def draw(self, top_row: int, height: int, width: int, *, highlight: bool) -> None:
+		if height <= 0:
+			return
+		if not self._entries:
+			draw_line(top_row, 0, "No commits", width)
+			return
+		self._ensure_cursor_visible(height)
+		for view_row in range(height):
+			index = self._scroll_offset + view_row
+			if index >= len(self._entries):
+				break
+			entry = self._entries[index]
+			label = _format_entry(entry, width)
+			attr = A_REVERSE if highlight and index == self._cursor else 0
+			draw_line(top_row + view_row, 0, label, width, attr)
+
+	def handle_nav_key(self, key: int, repo: Repository) -> LogNavKeyResult:
+		if key == KEY_RESIZE:
+			return LogNavKeyResult()
+		if key in (KEY_UP, ord("k"), ord("K")):
+			self._cursor = max(0, self._cursor - 1)
+			return LogNavKeyResult()
+		if key in (KEY_DOWN, ord("j"), ord("J")):
+			self._cursor = min(max(0, len(self._entries) - 1), self._cursor + 1)
+			return LogNavKeyResult()
+		self.refresh(repo)
+		return LogNavKeyResult()
+
+	def status_hint(self) -> str:
+		if not self._entries:
+			return "No commits — j/k move"
+		entry = self._entries[self._cursor]
+		return f"{entry.short_id} {entry.date} — j/k move"
+
+	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
+
+
+def _load_entries(repo: Repository) -> list[_LogEntry]:
+	try:
+		head = repo.head.target
+	except GitError:
+		return []
+	entries: list[_LogEntry] = []
+	for commit in repo.walk(head, GIT_SORT_TIME):
+		if not isinstance(commit, Commit):
+			continue
+		subject = commit.message.splitlines()[0] if commit.message else "(no message)"
+		entries.append(
+			_LogEntry(
+				short_id=str(commit.id)[:7],
+				date=_format_commit_date(commit),
+				subject=subject,
+			),
+		)
+		if len(entries) >= _MAX_COMMITS:
+			break
+	return entries
+
+
+def _format_commit_date(commit: Commit) -> str:
+	return datetime.fromtimestamp(commit.commit_time, tz=UTC).strftime("%Y-%m-%d")
+
+
+def _format_entry(entry: _LogEntry, width: int) -> str:
+	prefix = f"{entry.short_id} {entry.date} "
+	remaining = max(0, width - len(prefix))
+	subject = entry.subject
+	if len(subject) > remaining:
+		subject = subject[: max(0, remaining - 1)] + "…"
+	return f"{prefix}{subject}"[:width]
diff --git a/pygittools/tui/log_list_test.py b/pygittools/tui/log_list_test.py
new file mode 100644
index 0000000..a02959d
--- /dev/null
+++ b/pygittools/tui/log_list_test.py
@@ -0,0 +1,36 @@
+"""Tests for project label and commit log helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pygit2
+from pygit2 import Signature
+
+from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.project_label import repo_project_label
+
+SIG = Signature("t", "t@example.com", 0)
+
+
+def test_repo_project_label_falls_back_to_worktree(tmp_path: Path) -> None:
+	repo_dir = tmp_path / "standalone"
+	repo_dir.mkdir()
+	pygit2.init_repository(str(repo_dir), bare=False)
+	assert repo_project_label(repo_dir) == str(repo_dir.resolve())
+
+
+def test_repo_project_label_uses_worktree_for_git_dir(tmp_path: Path) -> None:
+	repo_dir = tmp_path / "pygitweb"
+	repo_dir.mkdir()
+	repo = pygit2.init_repository(str(repo_dir), bare=False)
+	assert repo_project_label(Path(repo.path)) == str(repo_dir.resolve())
+
+
+def test_commit_log_list_loads_head_commit(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, "feat: first", tree, [])
+	log = CommitLogList()
+	log.refresh(repo)
+	assert str(repo.head.target)[:7] in log.status_hint()
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
index 522cdb9..23c70c1 100644
--- a/pygittools/tui/pages/home.py
+++ b/pygittools/tui/pages/home.py
@@ -5,7 +5,6 @@ from __future__ import annotations
 from typing import Literal
 
 from unicurses import (  # type: ignore[import-untyped]
-	A_BOLD,
 	A_REVERSE,
 	KEY_RESIZE,
 )
@@ -14,13 +13,18 @@ from pygittools.tui.branches import current_branch_name
 from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.commit_draft import load_commit_draft
 from pygittools.tui.draw import draw_line
-from pygittools.tui.input_context import InputMode, input_context, nav_context
+from pygittools.tui.input_context import InputContext, InputMode, input_context, nav_context
+from pygittools.tui.log_list import CommitLogList
+from pygittools.tui.project_label import repo_project_label
+from pygittools.tui.tabs import draw_tab_row
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
 
-_HEADER_ROWS = 5
-_REPO_ROW = 2
-_BRANCH_ROW = 3
+_PROJECT_ROW = 0
+_BRANCH_ROW = 1
+_HEADER_ROWS = 2
+_TABS: tuple[str, ...] = ("Status", "Log")
 _HeaderFocus = Literal["changes", "repo", "branch"]
+_HomeTab = Literal["status", "log"]
 
 
 class HomePage:
@@ -29,6 +33,8 @@ class HomePage:
 	def __init__(self) -> None:
 		self._ctx: PageContext | None = None
 		self._changes = ChangesList()
+		self._log = CommitLogList()
+		self._active_tab: _HomeTab = "status"
 		self._header_focus: _HeaderFocus = "changes"
 		self._input_mode: InputMode = nav_context()
 
@@ -47,12 +53,25 @@ class HomePage:
 	def changes(self) -> ChangesList:
 		return self._changes
 
+	@property
+	def log(self) -> CommitLogList:
+		return self._log
+
+	@property
+	def active_tab(self) -> _HomeTab:
+		return self._active_tab
+
+	def set_active_tab(self, tab: _HomeTab) -> None:
+		self._active_tab = tab
+
 	def on_enter(self, ctx: PageContext) -> None:
 		self._ctx = ctx
+		self._active_tab = "status"
 		self._header_focus = "changes"
 		self._input_mode = nav_context()
 		self._changes.set_commit_message(load_commit_draft(ctx.repo_path, ctx.repo))
 		self._changes.refresh(ctx.repo)
+		self._log.refresh(ctx.repo)
 
 	def status_text(self) -> str:
 		return self._input_mode.status_hint(self)
@@ -64,27 +83,32 @@ class HomePage:
 			return
 
 		self._changes.refresh(ctx.repo)
-		worktree = ctx.repo.workdir or str(ctx.repo_path)
+		self._log.refresh(ctx.repo)
+		project_label = repo_project_label(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)
+
+		if height > _PROJECT_ROW:
+			active_index = 0 if self._active_tab == "status" else 1
+			prefix_attr = A_REVERSE if self._header_focus == "repo" else 0
+			draw_tab_row(_PROJECT_ROW, width, project_label, _TABS, active_index, prefix_attr=prefix_attr)
+		if height > _BRANCH_ROW:
+			branch_attr = A_REVERSE if self._header_focus == "branch" else 0
+			draw_line(_BRANCH_ROW, 0, f"Branch: {branch}", width, branch_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")
+			content_highlight = self._header_focus == "changes"
+			commit_input = isinstance(self._input_mode, InputContext)
+			if self._active_tab == "status":
+				self._changes.draw(
+					_HEADER_ROWS,
+					list_height,
+					width,
+					highlight=content_highlight,
+					commit_input=commit_input,
+				)
+			else:
+				self._log.draw(_HEADER_ROWS, list_height, width, highlight=content_highlight)
 
 	def handle_key(self, key: int) -> PageResult:
 		if key in (ord("q"), ord("Q")):
diff --git a/pygittools/tui/project_label.py b/pygittools/tui/project_label.py
new file mode 100644
index 0000000..c035202
--- /dev/null
+++ b/pygittools/tui/project_label.py
@@ -0,0 +1,29 @@
+"""Resolve a display label for the current repository."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from pygittools.tui.pygitweb_layout import load_pygitweb_layout
+
+
+def repo_project_label(repo_path: Path) -> str:
+	"""Return the PyGitWeb project path when under PROJECTROOT, else the worktree path."""
+	worktree = _worktree_path(repo_path)
+	try:
+		layout = load_pygitweb_layout()
+	except OSError:
+		return str(worktree)
+	projectroot = layout.projectroot.resolve()
+	try:
+		relative = worktree.relative_to(projectroot)
+	except ValueError:
+		return str(worktree)
+	return str(relative).replace("\\", "/")
+
+
+def _worktree_path(repo_path: Path) -> Path:
+	resolved = repo_path.resolve()
+	if resolved.name == ".git":
+		return resolved.parent
+	return resolved
diff --git a/pygittools/tui/tabs.py b/pygittools/tui/tabs.py
new file mode 100644
index 0000000..c144e56
--- /dev/null
+++ b/pygittools/tui/tabs.py
@@ -0,0 +1,84 @@
+"""Tab bar rendering for the home page header."""
+
+from __future__ import annotations
+
+from unicurses import (  # type: ignore[import-untyped]
+	A_BOLD,
+	COLOR_BLACK,
+	COLOR_PAIR,
+	COLOR_YELLOW,
+	has_colors,
+	init_pair,
+)
+
+from pygittools.tui.draw import clear_row, draw_text
+
+_TAB_GAP = "  "
+_TAB_ACTIVE_PAIR = 2
+_active_tab_attr = 0
+
+
+def tab_row_layout(prefix: str, tab_labels: tuple[str, ...]) -> list[tuple[int, str]]:
+	"""Return the starting column for each tab label."""
+	col = len(prefix) + len(_TAB_GAP)
+	layout: list[tuple[int, str]] = []
+	for index, label in enumerate(tab_labels):
+		if index > 0:
+			col += len(_TAB_GAP)
+		layout.append((col, label))
+		col += len(label)
+	return layout
+
+
+def init_tab_colors() -> None:
+	"""Initialize the color pair used to highlight the active tab."""
+	global _active_tab_attr
+	if has_colors():
+		init_pair(_TAB_ACTIVE_PAIR, COLOR_BLACK, COLOR_YELLOW)
+		_active_tab_attr = COLOR_PAIR(_TAB_ACTIVE_PAIR)
+	else:
+		_active_tab_attr = A_BOLD
+
+
+def draw_tab_row(
+	row: int,
+	width: int,
+	prefix: str,
+	tab_labels: tuple[str, ...],
+	active_index: int,
+	*,
+	prefix_attr: int = 0,
+) -> None:
+	"""Draw ``prefix`` followed by selectable tab labels on one row."""
+	if width <= 0:
+		return
+	clear_row(row)
+	clipped_prefix = _clip_prefix(prefix, width, tab_labels)
+	col = 0
+	col = draw_text(row, col, clipped_prefix, width, prefix_attr)
+	col = draw_text(row, col, _TAB_GAP, width, 0)
+	for index, label in enumerate(tab_labels):
+		if index > 0:
+			col = draw_text(row, col, _TAB_GAP, width, 0)
+		tab_attr = _active_tab_attr if index == active_index else 0
+		col = draw_text(row, col, label, width, tab_attr)
+
+
+def _tabs_reserved_width(tab_labels: tuple[str, ...]) -> int:
+	if not tab_labels:
+		return 0
+	total = len(_TAB_GAP)
+	for index, label in enumerate(tab_labels):
+		if index > 0:
+			total += len(_TAB_GAP)
+		total += len(label)
+	return total
+
+
+def _clip_prefix(prefix: str, width: int, tab_labels: tuple[str, ...]) -> str:
+	max_prefix = max(0, width - _tabs_reserved_width(tab_labels))
+	if len(prefix) <= max_prefix:
+		return prefix
+	if max_prefix <= 1:
+		return prefix[:max_prefix]
+	return prefix[: max_prefix - 1] + "…"
diff --git a/pygittools/tui/tabs_test.py b/pygittools/tui/tabs_test.py
new file mode 100644
index 0000000..f6abcc7
--- /dev/null
+++ b/pygittools/tui/tabs_test.py
@@ -0,0 +1,10 @@
+"""Tests for tab bar layout."""
+
+from __future__ import annotations
+
+from pygittools.tui.tabs import tab_row_layout
+
+
+def test_tab_row_layout_places_tabs_after_prefix() -> None:
+	layout = tab_row_layout("pygitweb", ("Status", "Log"))
+	assert layout == [(10, "Status"), (18, "Log")]
