diff --git a/pygittools/pyproject.toml b/pygittools/pyproject.toml
index 0ee04d0..8cb76e6 100644
--- a/pygittools/pyproject.toml
+++ b/pygittools/pyproject.toml
@@ -12,7 +12,7 @@ license-files = ["LICENSE"]
 requires-python = ">=3.11"
 dependencies = [
     "pygit2>=1.12.0",
-    "uni-curses>=3.1.2",
+    "pyratatui>=0.3.0",
 ]
 
 [project.scripts]
diff --git a/pygittools/tui/app.py b/pygittools/tui/app.py
index b9a4c75..771c53c 100644
--- a/pygittools/tui/app.py
+++ b/pygittools/tui/app.py
@@ -4,26 +4,15 @@ from __future__ import annotations
 
 from pathlib import Path
 
-from pygittools.tui.git_colors import init_tui_color_theme, status_bar_attr
+from pyratatui import Frame, Terminal
+
+from pygittools.tui.git_colors import init_tui_color_theme, status_bar_style
 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
+from pygittools.tui.status_bar import render_status_bar, split_content_status
 from pygittools.tui.tabs import init_tab_colors
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (  # type: ignore[import-untyped]
-	KEY_RESIZE,
-	cbreak,
-	clear,
-	curs_set,
-	endwin,
-	getch,
-	getmaxyx,
-	initscr,
-	keypad,
-	noecho,
-	refresh,
-)
 
 
 def run_tui(repo_path: Path | None = None) -> int:
@@ -32,43 +21,40 @@ def run_tui(repo_path: Path | None = None) -> int:
 	initial: Page = home_page() if ctx is not None else NoRepoPage(start)
 	stack: list[Page] = [initial]
 
-	stdscr = initscr()
-	try:
-		noecho()
-		cbreak()
-		curs_set(0)
-		keypad(stdscr, True)
-		init_tui_color_theme(ctx.repo if ctx is not None else None)
-		init_tab_colors()
-
-		if ctx is not None:
-			stack[0].on_enter(ctx)
-
-		while stack:
-			page = stack[-1]
-			height, width = getmaxyx(stdscr)
-			clear()
-			page.draw(stdscr, content_height(height), width)
-			if height > 0:
-				draw_status_bar(height - 1, width, page.status_text(), status_bar_attr())
-			refresh()
-
-			try:
-				key = getch()
-			except KeyboardInterrupt:
-				break
-			if key == KEY_RESIZE:
-				continue
-
-			result = page.handle_key(key)
-			ctx, stack = _apply_result(stack, ctx, result)
-			if result.action == PageAction.QUIT:
-				break
-	except KeyboardInterrupt:
-		pass
-	finally:
-		_persist_commit_draft(stack, ctx)
-		endwin()
+	with Terminal() as term:
+		term.hide_cursor()
+		try:
+			init_tui_color_theme(ctx.repo if ctx is not None else None)
+			init_tab_colors()
+
+			if ctx is not None:
+				stack[0].on_enter(ctx)
+
+			while stack:
+				page = stack[-1]
+
+				def ui(frame: Frame, _page: Page = page) -> None:
+					content, status = split_content_status(frame.area)
+					_page.draw(frame, content)
+					render_status_bar(frame, status, _page.status_text(), status_bar_style())
+
+				term.draw(ui)
+
+				try:
+					ev = term.poll_event(timeout_ms=100)
+				except KeyboardInterrupt:
+					break
+				if ev is None:
+					continue
+
+				result = page.handle_key(ev)
+				ctx, stack = _apply_result(stack, ctx, result)
+				if result.action == PageAction.QUIT:
+					break
+		except KeyboardInterrupt:
+			pass
+		finally:
+			_persist_commit_draft(stack, ctx)
 
 	return 0
 
diff --git a/pygittools/tui/keys.py b/pygittools/tui/keys.py
new file mode 100644
index 0000000..d5425e9
--- /dev/null
+++ b/pygittools/tui/keys.py
@@ -0,0 +1,79 @@
+"""Keyboard helpers for pyratatui KeyEvent values."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class KeyLike(Protocol):
+	@property
+	def code(self) -> str: ...
+
+	@property
+	def ctrl(self) -> bool: ...
+
+	@property
+	def alt(self) -> bool: ...
+
+	@property
+	def shift(self) -> bool: ...
+
+
+@dataclass(frozen=True, slots=True)
+class Key:
+	"""Test-friendly stand-in for ``pyratatui.KeyEvent``."""
+
+	code: str
+	ctrl: bool = False
+	alt: bool = False
+	shift: bool = False
+
+
+def key(code: str, *, ctrl: bool = False, alt: bool = False, shift: bool = False) -> Key:
+	return Key(code=code, ctrl=ctrl, alt=alt, shift=shift)
+
+
+def is_quit(ev: KeyLike) -> bool:
+	return ev.code.casefold() == "q" or (ev.code == "c" and ev.ctrl)
+
+
+def is_up(ev: KeyLike) -> bool:
+	return ev.code in {"Up", "k", "K"}
+
+
+def is_down(ev: KeyLike) -> bool:
+	return ev.code in {"Down", "j", "J"}
+
+
+def is_left(ev: KeyLike) -> bool:
+	return ev.code in {"Left", "h", "H"}
+
+
+def is_right(ev: KeyLike) -> bool:
+	return ev.code in {"Right", "l", "L"}
+
+
+def is_enter(ev: KeyLike) -> bool:
+	return ev.code == "Enter"
+
+
+def is_esc(ev: KeyLike) -> bool:
+	return ev.code == "Esc"
+
+
+def is_backspace(ev: KeyLike) -> bool:
+	return ev.code == "Backspace"
+
+
+def is_space(ev: KeyLike) -> bool:
+	return ev.code == " "
+
+
+def printable_char(ev: KeyLike) -> str | None:
+	if ev.ctrl or ev.alt:
+		return None
+	if len(ev.code) == 1 and ev.code.isprintable():
+		return ev.code
+	return None
diff --git a/pygittools/tui/pages/help.py b/pygittools/tui/pages/help.py
index 74fbf0f..02456ba 100644
--- a/pygittools/tui/pages/help.py
+++ b/pygittools/tui/pages/help.py
@@ -4,13 +4,10 @@ from __future__ import annotations
 
 from importlib.resources import files
 
-from pygittools.tui.draw import draw_line
+from pyratatui import Frame, List, ListItem, ListState, Rect
+
+from pygittools.tui.keys import KeyLike, is_down, is_esc, is_quit, is_up
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (  # type: ignore[import-untyped]
-	KEY_DOWN,
-	KEY_RESIZE,
-	KEY_UP,
-)
 
 
 def _load_help_lines() -> list[str]:
@@ -23,7 +20,9 @@ class HelpPage:
 
 	def __init__(self) -> None:
 		self._lines = _load_help_lines()
-		self._offset = 0
+		self._state = ListState()
+		if self._lines:
+			self._state.select(0)
 
 	def on_enter(self, ctx: PageContext) -> None:
 		del ctx
@@ -32,31 +31,26 @@ class HelpPage:
 		total = len(self._lines)
 		if total == 0:
 			return "Help"
-		visible_end = min(self._offset + 1, total)
-		return f"Help {visible_end}/{total} — J/K or arrows to scroll, Q or Esc to go back"
+		selected = self._state.selected
+		visible = (selected + 1) if selected is not None else 1
+		return f"Help {visible}/{total} — J/K or arrows to scroll, Q or Esc to go back"
 
-	def draw(self, stdscr: int, height: int, width: int) -> None:
-		del stdscr
-		if height <= 0:
+	def draw(self, frame: Frame, area: Rect) -> None:
+		if area.height <= 0 or area.width <= 0:
 			return
-		max_offset = max(0, len(self._lines) - height)
-		self._offset = min(self._offset, max_offset)
-		for row in range(height):
-			line_index = self._offset + row
-			if line_index >= len(self._lines):
-				break
-			draw_line(row, 0, self._lines[line_index], width)
+		items = [ListItem(line if line else " ") for line in self._lines]
+		frame.render_stateful_list(List(items), area, self._state)
 
-	def handle_key(self, key: int) -> PageResult:
-		if key in (ord("q"), ord("Q"), 27):
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_quit(key) or is_esc(key):
 			return PageResult(action=PageAction.POP)
-		if key == KEY_RESIZE:
-			return PageResult()
-		if key in (KEY_UP, ord("k"), ord("K")):
-			self._offset = max(0, self._offset - 1)
+		if is_up(key):
+			selected = self._state.selected or 0
+			self._state.select(max(0, selected - 1))
 			return PageResult()
-		if key in (KEY_DOWN, ord("j"), ord("J")):
-			self._offset += 1
+		if is_down(key):
+			selected = self._state.selected or 0
+			self._state.select(min(max(0, len(self._lines) - 1), selected + 1))
 			return PageResult()
 		return PageResult()
 
diff --git a/pygittools/tui/pages/no_repo.py b/pygittools/tui/pages/no_repo.py
index 75af1d5..4038161 100644
--- a/pygittools/tui/pages/no_repo.py
+++ b/pygittools/tui/pages/no_repo.py
@@ -4,10 +4,11 @@ from __future__ import annotations
 
 from pathlib import Path
 
-from pygittools.tui.draw import draw_line
+from pyratatui import Frame, Paragraph, Rect, Style, Text
+
+from pygittools.tui.keys import KeyLike, is_quit
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import A_BOLD, KEY_RESIZE
 
 
 class NoRepoPage:
@@ -22,26 +23,22 @@ class NoRepoPage:
 	def status_text(self) -> str:
 		return "no repository"
 
-	def draw(self, stdscr: int, height: int, width: int) -> None:
-		del stdscr
-		lines: list[tuple[str, int]] = [
-			("pgt", A_BOLD),
-			("", 0),
-			(f"No git repository found under {self._start}", 0),
-			("", 0),
-			("Run pgt from inside a repository, or pass --repo PATH.", 0),
-			("", 0),
-		]
-		for row, (text, attr) in enumerate(lines):
-			if row >= height:
-				break
-			draw_line(row, 0, text, width, attr)
-
-	def handle_key(self, key: int) -> PageResult:
-		if key in (ord("q"), ord("Q")):
+	def draw(self, frame: Frame, area: Rect) -> None:
+		lines = "\n".join([
+			"pgt",
+			"",
+			f"No git repository found under {self._start}",
+			"",
+			"Run pgt from inside a repository, or pass --repo PATH.",
+		])
+		frame.render_widget(
+			Paragraph(Text.from_string(lines)).style(Style().bold()),
+			area,
+		)
+
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_quit(key):
 			return PageResult(action=PageAction.QUIT)
-		if key in (ord("h"), ord("H")):
+		if key.code.casefold() == "h":
 			return PageResult(action=PageAction.PUSH, next_page=help_page())
-		if key == KEY_RESIZE:
-			return PageResult()
 		return PageResult()
diff --git a/pygittools/tui/status_bar.py b/pygittools/tui/status_bar.py
index 0d52769..05de78c 100644
--- a/pygittools/tui/status_bar.py
+++ b/pygittools/tui/status_bar.py
@@ -2,22 +2,29 @@
 
 from __future__ import annotations
 
-from pygittools.tui.ucurses import mvaddstr  # type: ignore[import-untyped]
+from pyratatui import Constraint, Direction, Frame, Layout, Paragraph, Rect, Style
 
 _DEFAULT_HINT = "q quit"
 
 
-def content_height(full_height: int) -> int:
-	"""Rows available to page content above the status bar."""
-	return max(0, full_height - 1)
+def split_content_status(area: Rect) -> tuple[Rect, Rect]:
+	"""Split the frame into content above a one-row status bar."""
+	chunks = Layout().direction(Direction.Vertical).constraints([Constraint.fill(1), Constraint.length(1)]).split(area)
+	return chunks[0], chunks[1]
 
 
-def draw_status_bar(row: int, width: int, text: str, attr: int, hint: str = _DEFAULT_HINT) -> None:
-	"""Draw the status bar on the last screen row."""
-	if width <= 0:
+def render_status_bar(
+	frame: Frame,
+	area: Rect,
+	text: str,
+	style: Style,
+	hint: str = _DEFAULT_HINT,
+) -> None:
+	"""Draw the status bar into ``area``."""
+	if area.width <= 0 or area.height <= 0:
 		return
-	line = _format_line(text, hint, width)
-	mvaddstr(row, 0, line, attr)
+	line = _format_line(text, hint, area.width)
+	frame.render_widget(Paragraph.from_string(line).style(style), area)
 
 
 def _format_line(left: str, right: str, width: int) -> str:
diff --git a/pygittools/tui/types.py b/pygittools/tui/types.py
index 57ec8e4..b458438 100644
--- a/pygittools/tui/types.py
+++ b/pygittools/tui/types.py
@@ -8,6 +8,9 @@ from pathlib import Path
 from typing import Protocol, runtime_checkable
 
 from pygit2 import Repository
+from pyratatui import Frame, Rect
+
+from pygittools.tui.keys import KeyLike
 
 
 class PageAction(Enum):
@@ -40,8 +43,8 @@ class Page(Protocol):
 
 	def on_enter(self, ctx: PageContext) -> None: ...
 
-	def draw(self, stdscr: int, height: int, width: int) -> None: ...
+	def draw(self, frame: Frame, area: Rect) -> None: ...
 
 	def status_text(self) -> str: ...
 
-	def handle_key(self, key: int) -> PageResult: ...
+	def handle_key(self, key: KeyLike) -> PageResult: ...
diff --git a/uv.lock b/uv.lock
index 9a708d2..d225ead 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1156,13 +1156,13 @@ version = "0.2.2"
 source = { editable = "pygittools" }
 dependencies = [
     { name = "pygit2" },
-    { name = "uni-curses" },
+    { name = "pyratatui" },
 ]
 
 [package.metadata]
 requires-dist = [
     { name = "pygit2", specifier = ">=1.12.0" },
-    { name = "uni-curses", specifier = ">=3.1.2" },
+    { name = "pyratatui", specifier = ">=0.3.0" },
 ]
 
 [[package]]
@@ -1258,6 +1258,20 @@ crypto = [
 ]
 
 [[package]]
+name = "pyratatui"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/89/17efc06e6e137051ce46cf0a67bf1842d112fd14f5ab3e310f6f57d5cc8a/pyratatui-0.3.0.tar.gz", hash = "sha256:6ac8f98f041a1b8e06bb56065fc9aeb3904941ff830a0ba9bf7a512ae9981d95", size = 84083, upload-time = "2026-09-17T12:44:03.787Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7d/d3/2fc9766bdba80c3593cc4a2cbc136a07e895a431f07a52f40d85dc06fe28/pyratatui-0.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:192636d9e9391987b1a4121d7d0c207f33e338d16f6cdeddf83f49eeb5908f73", size = 735924, upload-time = "2026-09-17T12:43:54.343Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/88/e2c11b8e2f7b22c070f15f810ba86e4e52dbca2fe0c045497a67b282fe77/pyratatui-0.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8ff37d261faef28402216701dd9a8acdc47baad2fcd219db04ae398af0eb60ac", size = 695085, upload-time = "2026-09-17T12:43:56.069Z" },
+    { url = "https://files.pythonhosted.org/packages/23/ae/9e834682fed6d095984ad6258ec186da6be6ce5553795adbced77848dc38/pyratatui-0.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae567c33161606b6d82a0c8755994778ac834e4288a58959af651e04b7acfe0", size = 748166, upload-time = "2026-09-17T12:43:57.527Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/10/37c21aada277b103275156390e5ed5601cf42f5a89fa70f7c112c0929827/pyratatui-0.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:316f15d7428d60f88dcf8de7b403ca40e09912500d299dc3ed80fa2d28ff0728", size = 893286, upload-time = "2026-09-17T12:43:58.973Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/1f/a9b0814992090771fb61bc504066b16285c788faceb151b5ae0f952edd72/pyratatui-0.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8240ae0bae0720ca8598023886000f72d48e4c395d01a9ac923f192555badfc", size = 958230, upload-time = "2026-09-17T12:44:00.784Z" },
+    { url = "https://files.pythonhosted.org/packages/31/75/e0ba42f28d3d36f3f72da160dd9c5d21e1d5a5db06acbf055abfc3a06c49/pyratatui-0.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:9d8496ad7dd0c73cab67cccf7535c1d3bea1300aef16beae69a1ec1598c9d84b", size = 662308, upload-time = "2026-09-17T12:44:02.376Z" },
+]
+
+[[package]]
 name = "pytest"
 version = "9.0.3"
 source = { registry = "https://pypi.org/simple" }
@@ -1674,15 +1688,6 @@ wheels = [
 ]
 
 [[package]]
-name = "uni-curses"
-version = "3.1.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "x256" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b2/6a/3e66a35990254e81f66a0989471429a140d0adc518c1d9ea9b8a0912f5cd/Uni-Curses-3.1.2.tar.gz", hash = "sha256:3389a3766cb63527f92a24d56339cfcbce9b560641e6ed6f0e2e86207d45adce", size = 3660518, upload-time = "2024-07-05T09:51:18.249Z" }
-
-[[package]]
 name = "uvicorn"
 version = "0.44.0"
 source = { registry = "https://pypi.org/simple" }
@@ -1889,9 +1894,3 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
     { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
 ]
-
-[[package]]
-name = "x256"
-version = "0.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4b/f5/2be54b37a736ede81168fa2bc4ac43655a3fecab03f903a82ecd0560a821/x256-0.0.3.tar.gz", hash = "sha256:f855dbccd91e53f5890283d8203855743827e7eed595d5cf19544ba3d212e001", size = 3774, upload-time = "2013-12-11T21:33:58.034Z" }
