diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
index 78753cc..8116d5e 100644
--- a/pygittools/tui/input_context.py
+++ b/pygittools/tui/input_context.py
@@ -6,23 +6,16 @@ from dataclasses import dataclass
 from typing import TYPE_CHECKING, Literal, Protocol
 
 from pygittools.tui.changes_list import ChangesList
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_left, is_right, is_up
 from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.branches import branch_picker_page
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.pages.projects import projects_page
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (
-	KEY_DOWN,
-	KEY_ENTER,
-	KEY_LEFT,
-	KEY_RIGHT,
-	KEY_UP,
-)
 
 if TYPE_CHECKING:
 	from pygittools.tui.tasks_list import TasksBoardList
 
-_KEY_ESC = 27
 _HeaderFocus = Literal["changes", "repo", "branch"]
 _HomeTab = Literal["status", "log", "tasks"]
 
@@ -52,6 +45,7 @@ class PageInputHost(Protocol):
 	def active_tab(self) -> _HomeTab: ...
 
 	def set_active_tab(self, tab: _HomeTab) -> None: ...
+
 	@property
 	def tasks(self) -> TasksBoardList: ...
 
@@ -70,26 +64,26 @@ class NavContext:
 			return host.tasks.status_hint()
 		return host.changes.status_hint()
 
-	def dispatch_key(self, host: PageInputHost, key: int) -> ContextDispatch:
+	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
 		if _try_switch_tab(host, key):
 			return ContextDispatch()
 
-		if key == ord("?"):
+		if key.code == "?":
 			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=help_page()))
 
 		if host.header_focus == "repo":
-			if key in (KEY_DOWN, ord("j"), ord("J")):
+			if is_down(key):
 				host.set_header_focus("branch")
-			elif key in (KEY_ENTER, 10, 13):
+			elif is_enter(key):
 				return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()))
 			return ContextDispatch()
 
 		if host.header_focus == "branch":
-			if key in (KEY_UP, ord("k"), ord("K")):
+			if is_up(key):
 				host.set_header_focus("repo")
-			elif key in (KEY_DOWN, ord("j"), ord("J")):
+			elif is_down(key):
 				host.set_header_focus("changes")
-			elif key in (KEY_ENTER, 10, 13):
+			elif is_enter(key):
 				if host.active_tab == "tasks" and host.page_context is not None:
 					host.tasks.sync_with_default_remote(host.page_context.repo)
 					return ContextDispatch()
@@ -98,7 +92,7 @@ class NavContext:
 				)
 			return ContextDispatch()
 
-		if key in (KEY_UP, ord("k"), ord("K")) and _content_list_is_at_top(host):
+		if is_up(key) and _content_list_is_at_top(host):
 			host.set_header_focus("branch")
 			return ContextDispatch()
 
@@ -122,8 +116,8 @@ class InputContext:
 		del host
 		return "Esc relinquish input — Enter commit"
 
-	def dispatch_key(self, host: PageInputHost, key: int) -> ContextDispatch:
-		if key == _KEY_ESC:
+	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+		if is_esc(key):
 			return ContextDispatch(next_mode="nav")
 		ctx = host.page_context
 		if ctx is not None:
@@ -152,15 +146,15 @@ def _content_list_is_at_top(host: PageInputHost) -> bool:
 	return host.changes.is_at_top()
 
 
-def _try_switch_tab(host: PageInputHost, key: int) -> bool:
+def _try_switch_tab(host: PageInputHost, key: KeyLike) -> bool:
 	tabs: tuple[_HomeTab, ...] = ("status", "log", "tasks")
 	current = host.active_tab
 	index = tabs.index(current)
-	if key in (KEY_LEFT, ord("h"), ord("H")):
+	if is_left(key):
 		if index > 0:
 			host.set_active_tab(tabs[index - 1])
 		return True
-	if key in (KEY_RIGHT, ord("l"), ord("L")):
+	if is_right(key):
 		if index < len(tabs) - 1:
 			host.set_active_tab(tabs[index + 1])
 		return True
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
index e0534f3..dd4cc21 100644
--- a/pygittools/tui/pages/home.py
+++ b/pygittools/tui/pages/home.py
@@ -4,24 +4,20 @@ from __future__ import annotations
 
 from typing import Literal
 
+from pyratatui import Constraint, Direction, Frame, Layout, Paragraph, Rect
+
 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.git_colors import current_theme, focus_attr
+from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.input_context import InputContext, InputMode, input_context, nav_context
+from pygittools.tui.keys import KeyLike, is_quit
 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.tabs import render_tab_row
 from pygittools.tui.tasks_list import TasksBoardList
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (  # type: ignore[import-untyped]
-	KEY_RESIZE,
-)
 
-_PROJECT_ROW = 0
-_BRANCH_ROW = 1
-_HEADER_ROWS = 2
 _TABS: tuple[str, ...] = ("Status", "Log", "Tasks")
 _HeaderFocus = Literal["changes", "repo", "branch"]
 _HomeTab = Literal["status", "log", "tasks"]
@@ -85,10 +81,9 @@ class HomePage:
 	def status_text(self) -> str:
 		return self._input_mode.status_hint(self)
 
-	def draw(self, stdscr: int, height: int, width: int) -> None:
-		del stdscr
+	def draw(self, frame: Frame, area: Rect) -> None:
 		ctx = self._ctx
-		if ctx is None:
+		if ctx is None or area.height <= 0:
 			return
 
 		self._changes.refresh(ctx.repo)
@@ -97,46 +92,57 @@ class HomePage:
 		project_label = repo_project_label(ctx.repo_path)
 		branch = current_branch_name(ctx.repo)
 
-		if height > _PROJECT_ROW:
-			try:
-				active_index = _TAB_KEYS.index(self._active_tab)
-			except ValueError:
-				active_index = 0
-			theme = current_theme()
-			prefix_attr = focus_attr(theme.branch) 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:
-			theme = current_theme()
-			branch_attr = theme.branch
-			if self._header_focus == "branch":
-				branch_attr = focus_attr(branch_attr)
-			branch_label = f"Branch: {branch}"
-			if self._active_tab == "tasks":
-				branch_label = f"{branch_label} — [Sync tasks]"
-			draw_line(_BRANCH_ROW, 0, branch_label, width, branch_attr)
-
-		list_height = max(0, height - _HEADER_ROWS)
-		if list_height > 0 and height > _HEADER_ROWS:
-			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,
-				)
-			elif self._active_tab == "log":
-				self._log.draw(_HEADER_ROWS, list_height, width, highlight=content_highlight)
-			else:
-				self._tasks.draw(_HEADER_ROWS, list_height, width, highlight=content_highlight)
-
-	def handle_key(self, key: int) -> PageResult:
-		if key in (ord("q"), ord("Q")):
+		chunks = (
+			Layout()
+			.direction(Direction.Vertical)
+			.constraints([
+				Constraint.length(1),
+				Constraint.length(1),
+				Constraint.fill(1),
+			])
+			.split(area)
+		)
+
+		try:
+			active_index = _TAB_KEYS.index(self._active_tab)
+		except ValueError:
+			active_index = 0
+		render_tab_row(
+			frame,
+			chunks[0],
+			project_label,
+			_TABS,
+			active_index,
+			prefix_focused=self._header_focus == "repo",
+		)
+
+		theme = current_theme()
+		branch_style = theme.branch
+		if self._header_focus == "branch":
+			branch_style = focus_style(branch_style)
+		branch_label = f"Branch: {branch}"
+		if self._active_tab == "tasks":
+			branch_label = f"{branch_label} — [Sync tasks]"
+		frame.render_widget(Paragraph.from_string(branch_label).style(branch_style), chunks[1])
+
+		content = chunks[2]
+		content_highlight = self._header_focus == "changes"
+		commit_input = isinstance(self._input_mode, InputContext)
+		if self._active_tab == "status":
+			self._changes.draw(
+				frame,
+				content,
+				highlight=content_highlight,
+				commit_input=commit_input,
+			)
+		elif self._active_tab == "log":
+			self._log.draw(frame, content, highlight=content_highlight)
+		else:
+			self._tasks.draw(frame, content, highlight=content_highlight)
+
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_quit(key):
 			return PageResult(action=PageAction.QUIT)
-		if key == KEY_RESIZE:
-			return PageResult()
 
 		dispatch = self._input_mode.dispatch_key(self, key)
 		if dispatch.next_mode == "input":
diff --git a/pygittools/tui/status_bar_test.py b/pygittools/tui/status_bar_test.py
index d9f8ba4..769d73a 100644
--- a/pygittools/tui/status_bar_test.py
+++ b/pygittools/tui/status_bar_test.py
@@ -1,12 +1,17 @@
 from __future__ import annotations
 
-from pygittools.tui.git_colors import init_tui_color_theme, status_bar_attr
-from pygittools.tui.status_bar import _format_line, content_height, draw_status_bar
+from pygittools.tui.git_colors import init_tui_color_theme, status_bar_style
+from pygittools.tui.status_bar import _format_line, split_content_status
+from pyratatui import Rect, Style
+from unittest.mock import MagicMock
 
+from pygittools.tui.status_bar import render_status_bar
 
-def test_content_height_reserves_status_row() -> None:
-	assert content_height(10) == 9
-	assert content_height(0) == 0
+
+def test_split_content_status_reserves_row() -> None:
+	content, status = split_content_status(Rect(0, 0, 80, 10))
+	assert content.height == 9
+	assert status.height == 1
 
 
 def test_format_line_with_hint() -> None:
@@ -29,10 +34,12 @@ def test_format_line_tight_width() -> None:
 	assert len(line) == 4
 
 
-def test_draw_status_bar_skips_zero_width() -> None:
-	draw_status_bar(0, 0, "x", 0)
+def test_render_status_bar_skips_zero_width() -> None:
+	frame = MagicMock()
+	render_status_bar(frame, Rect(0, 0, 0, 1), "x", Style())
+	frame.render_widget.assert_not_called()
 
 
-def test_status_bar_attr_after_init() -> None:
+def test_status_bar_style_after_init() -> None:
 	init_tui_color_theme(None)
-	assert status_bar_attr() != 0
+	assert status_bar_style() is not None
diff --git a/pygittools/tui/tabs.py b/pygittools/tui/tabs.py
index 024bbfd..c6e9f20 100644
--- a/pygittools/tui/tabs.py
+++ b/pygittools/tui/tabs.py
@@ -2,22 +2,9 @@
 
 from __future__ import annotations
 
-from pygittools.tui.draw import clear_row, draw_text
-from pygittools.tui.git_colors import active_tab_attr, current_theme
+from pyratatui import Constraint, Direction, Frame, Layout, Paragraph, Rect, Tabs
 
-_TAB_GAP = "  "
-
-
-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
+from pygittools.tui.git_colors import active_tab_style, current_theme, focus_style
 
 
 def init_tab_colors() -> None:
@@ -25,47 +12,33 @@ def init_tab_colors() -> None:
 	return None
 
 
-def draw_tab_row(
-	row: int,
-	width: int,
+def render_tab_row(
+	frame: Frame,
+	area: Rect,
 	prefix: str,
 	tab_labels: tuple[str, ...],
 	active_index: int,
 	*,
-	prefix_attr: int = 0,
+	prefix_focused: bool = False,
 ) -> None:
-	"""Draw ``prefix`` followed by selectable tab labels on one row."""
-	if width <= 0:
+	"""Draw project prefix and selectable tabs into ``area``."""
+	if area.height <= 0 or area.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)
-	tab_active = active_tab_attr()
-	tab_inactive = current_theme().tab_inactive
-	for index, label in enumerate(tab_labels):
-		if index > 0:
-			col = draw_text(row, col, _TAB_GAP, width, 0)
-		tab_attr = tab_active if index == active_index else tab_inactive
-		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] + "…"
+	theme = current_theme()
+	prefix_style = focus_style(theme.branch) if prefix_focused else theme.branch
+	prefix_width = min(area.width, len(prefix) + 2)
+	chunks = (
+		Layout()
+		.direction(Direction.Horizontal)
+		.constraints([Constraint.length(prefix_width), Constraint.fill(1)])
+		.split(area)
+	)
+	frame.render_widget(Paragraph.from_string(prefix).style(prefix_style), chunks[0])
+	frame.render_widget(
+		Tabs(list(tab_labels))
+		.select(active_index)
+		.highlight_style(active_tab_style())
+		.style(theme.tab_inactive)
+		.divider("  "),
+		chunks[1],
+	)
diff --git a/pygittools/tui/tabs_test.py b/pygittools/tui/tabs_test.py
index bb2b0c3..e31d3e5 100644
--- a/pygittools/tui/tabs_test.py
+++ b/pygittools/tui/tabs_test.py
@@ -1,16 +1,28 @@
-"""Tests for tab bar layout."""
+"""Tests for home tab row rendering."""
 
 from __future__ import annotations
 
+from unittest.mock import MagicMock
+
+from pyratatui import Rect
+
 from pygittools.tui.git_colors import init_tui_color_theme
-from pygittools.tui.tabs import draw_tab_row, tab_row_layout
+from pygittools.tui.tabs import init_tab_colors, render_tab_row
 
 
-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_init_tab_colors_is_noop() -> None:
+	init_tui_color_theme(None)
+	init_tab_colors()
 
 
-def test_draw_tab_row_renders_without_error() -> None:
+def test_render_tab_row() -> None:
 	init_tui_color_theme(None)
-	draw_tab_row(0, 80, "project", ("Status", "Log"), 1, prefix_attr=0)
+	frame = MagicMock()
+	render_tab_row(frame, Rect(0, 0, 80, 1), "demo", ("Status", "Log", "Tasks"), 1)
+	assert frame.render_widget.call_count >= 2
+
+
+def test_render_tab_row_zero_area() -> None:
+	frame = MagicMock()
+	render_tab_row(frame, Rect(0, 0, 0, 0), "demo", ("Status",), 0)
+	frame.render_widget.assert_not_called()
