diff --git a/pygittools/tui/pages/branches.py b/pygittools/tui/pages/branches.py
index 804fc9a..9dcfd50 100644
--- a/pygittools/tui/pages/branches.py
+++ b/pygittools/tui/pages/branches.py
@@ -2,25 +2,29 @@
 
 from __future__ import annotations
 
+from pyratatui import (
+	Constraint,
+	Direction,
+	Frame,
+	Layout,
+	List,
+	ListItem,
+	ListState,
+	Paragraph,
+	Rect,
+	TextPrompt,
+	TextState,
+)
+
 from pygittools.tui.branches import (
 	checkout_branch,
 	create_branch_from_current,
 	current_branch_name,
 	list_local_branches,
 )
-from pygittools.tui.draw import draw_line
+from pygittools.tui.git_colors import current_theme, focus_style
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (  # type: ignore[import-untyped]
-	A_BOLD,
-	A_REVERSE,
-	KEY_BACKSPACE,
-	KEY_DOWN,
-	KEY_ENTER,
-	KEY_RESIZE,
-	KEY_UP,
-)
-
-_FILTER_PREFIX = "Filter: "
 
 
 class BranchPickerPage:
@@ -28,58 +32,68 @@ class BranchPickerPage:
 
 	def __init__(self) -> None:
 		self._ctx: PageContext | None = None
-		self._filter = ""
+		self._filter_state = TextState()
+		self._filter_state.focus()
 		self._branches: list[str] = []
 		self._filtered: list[str] = []
-		self._cursor = 0
-		self._scroll_offset = 0
+		self._list_state = ListState()
+		self._focus_filter = True
 		self._error: str | None = None
 
 	def on_enter(self, ctx: PageContext) -> None:
 		self._ctx = ctx
-		self._filter = ""
+		self._filter_state.set_value("")
+		self._filter_state.focus()
+		self._focus_filter = True
 		self._error = None
-		self._cursor = 0
-		self._scroll_offset = 0
+		self._list_state.select(0)
 		self._reload_branches()
 
 	def status_text(self) -> str:
 		if self._error:
 			return self._error
 		if not self._filtered:
-			if self._filter.strip():
+			if self._filter_state.value().strip():
 				return "Enter create branch — type name — Esc back"
 			return "No matching branches — type to filter, Esc back"
 		return "Enter checkout — type to filter — j/k move — Esc back"
 
-	def draw(self, stdscr: int, height: int, width: int) -> None:
-		del stdscr
-		if height <= 0:
+	def draw(self, frame: Frame, area: Rect) -> None:
+		if area.height <= 0:
 			return
+		theme = current_theme()
+		chunks = (
+			Layout()
+			.direction(Direction.Vertical)
+			.constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
+			.split(area)
+		)
+		frame.render_widget(Paragraph.from_string("Switch branch").style(theme.header), chunks[0])
+		if self._focus_filter:
+			self._filter_state.focus()
+		else:
+			self._filter_state.blur()
+		frame.render_text_prompt(TextPrompt("Filter: "), chunks[1], self._filter_state)
+
 		current = current_branch_name(self._ctx.repo) if self._ctx is not None else ""
-		draw_line(0, 0, "Switch branch", width, A_BOLD)
-		if height > 1:
-			draw_line(1, 0, self._filter_label(width), width, A_REVERSE if self._cursor == 0 else 0)
-		list_top = 2
-		list_height = height - list_top
-		if list_height <= 0:
-			return
-		self._ensure_cursor_visible(list_top, list_height)
-		for view_row in range(list_height):
-			index = self._scroll_offset + view_row
-			if index >= len(self._filtered):
-				break
-			branch = self._filtered[index]
+		items: list[ListItem] = []
+		selected = self._list_state.selected or 0
+		for index, branch in enumerate(self._filtered):
 			marker = "* " if branch == current else "  "
-			row_index = index + 1
-			attr = A_REVERSE if self._cursor == row_index else 0
-			draw_line(list_top + view_row, 0, f"{marker}{branch}", width, attr)
-
-	def handle_key(self, key: int) -> PageResult:
-		if key in (ord("q"), ord("Q"), 27):
+			focused = not self._focus_filter and index == selected
+			style = focus_style(theme.normal) if focused else theme.normal
+			items.append(ListItem(f"{marker}{branch}", style))
+		if not items:
+			items.append(ListItem("(no matches)", theme.log_date))
+		frame.render_stateful_list(
+			List(items).highlight_style(theme.focus).highlight_symbol(""),
+			chunks[2],
+			self._list_state,
+		)
+
+	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 self._error is not None:
 			self._error = None
 
@@ -87,41 +101,49 @@ class BranchPickerPage:
 		if ctx is None:
 			return PageResult()
 
-		max_cursor = max(0, len(self._filtered))
-		if key in (KEY_UP, ord("k"), ord("K")):
-			self._cursor = max(0, self._cursor - 1)
-			return PageResult()
-		if key in (KEY_DOWN, ord("j"), ord("J")):
-			self._cursor = min(max_cursor, self._cursor + 1)
-			return PageResult()
-
-		if self._cursor == 0:
-			if key in (KEY_BACKSPACE, 127, 8):
-				self._filter = self._filter[:-1]
-				self._apply_filter()
-				return PageResult()
-			if 32 <= key <= 126:
-				self._filter += chr(key)
-				self._apply_filter()
+		if self._focus_filter:
+			if is_down(key) and self._filtered:
+				self._focus_filter = False
+				self._list_state.select(0)
 				return PageResult()
-			if key in (KEY_ENTER, 10, 13):
+			if is_enter(key):
 				if not self._filtered:
 					return self._try_create_branch(ctx)
-				self._cursor = 1
+				self._focus_filter = False
+				self._list_state.select(0)
 				return PageResult()
+			self._filter_state.focus()
+			self._filter_state.handle_key(key)
+			self._apply_filter()
+			return PageResult()
 
-		if key in (KEY_ENTER, 10, 13) and self._cursor > 0:
-			branch = self._filtered[self._cursor - 1]
+		if is_up(key):
+			selected = self._list_state.selected or 0
+			if selected <= 0:
+				self._focus_filter = True
+				return PageResult()
+			self._list_state.select(selected - 1)
+			return PageResult()
+		if is_down(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(min(max(0, len(self._filtered) - 1), selected + 1))
+			return PageResult()
+		if is_enter(key) and self._filtered:
+			branch = self._filtered[self._list_state.selected or 0]
 			ok, error = checkout_branch(ctx.repo, branch)
 			if ok:
 				return PageResult(action=PageAction.POP)
 			self._error = error
 			return PageResult()
-
+		# Typing jumps back to filter.
+		self._focus_filter = True
+		self._filter_state.focus()
+		self._filter_state.handle_key(key)
+		self._apply_filter()
 		return PageResult()
 
 	def _try_create_branch(self, ctx: PageContext) -> PageResult:
-		ok, error = create_branch_from_current(ctx.repo, self._filter)
+		ok, error = create_branch_from_current(ctx.repo, self._filter_state.value())
 		if ok:
 			return PageResult(action=PageAction.POP)
 		self._error = error
@@ -136,32 +158,13 @@ class BranchPickerPage:
 		self._apply_filter()
 
 	def _apply_filter(self) -> None:
-		needle = self._filter.casefold()
+		needle = self._filter_state.value().casefold()
 		if needle:
 			self._filtered = [branch for branch in self._branches if needle in branch.casefold()]
 		else:
 			self._filtered = list(self._branches)
-		max_cursor = max(0, len(self._filtered))
-		self._cursor = min(self._cursor, max_cursor)
-
-	def _filter_label(self, width: int) -> str:
-		max_text = max(0, width - len(_FILTER_PREFIX) - 1)
-		text = self._filter[-max_text:] if len(self._filter) > max_text else self._filter
-		label = f"{_FILTER_PREFIX}{text}"
-		if self._cursor == 0:
-			label += "_"
-		return label[:width]
-
-	def _ensure_cursor_visible(self, list_top: int, list_height: int) -> None:
-		del list_top
-		if self._cursor <= 0:
-			self._scroll_offset = 0
-			return
-		list_index = self._cursor - 1
-		if list_index < self._scroll_offset:
-			self._scroll_offset = list_index
-		elif list_index >= self._scroll_offset + list_height:
-			self._scroll_offset = list_index - list_height + 1
+		selected = self._list_state.selected or 0
+		self._list_state.select(min(selected, max(0, len(self._filtered) - 1)))
 
 
 def branch_picker_page() -> BranchPickerPage:
diff --git a/pygittools/tui/pages/projects.py b/pygittools/tui/pages/projects.py
index fce09e2..3f48edf 100644
--- a/pygittools/tui/pages/projects.py
+++ b/pygittools/tui/pages/projects.py
@@ -2,21 +2,15 @@
 
 from __future__ import annotations
 
-from pygittools.tui.draw import draw_line
+from pyratatui import Constraint, Direction, Frame, Layout, List, ListItem, ListState, Paragraph, Rect
+
+from pygittools.tui.git_colors import current_theme, focus_style
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
 from pygittools.tui.projects import ProjectEntry, list_projects
 from pygittools.tui.pygitweb_layout import load_pygitweb_layout
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (
-	A_BOLD,
-	A_REVERSE,
-	KEY_DOWN,
-	KEY_ENTER,
-	KEY_RESIZE,
-	KEY_UP,
-)
 
 _BRANCH_WIDTH = 16
-_TABLE_TOP = 2
 
 
 class ProjectsPage:
@@ -25,8 +19,7 @@ class ProjectsPage:
 	def __init__(self) -> None:
 		self._projects: list[ProjectEntry] = []
 		self._projectroot_label = ""
-		self._cursor = 0
-		self._scroll_offset = 0
+		self._list_state = ListState()
 		self._error: str | None = None
 
 	def on_enter(self, ctx: PageContext) -> None:
@@ -34,8 +27,7 @@ class ProjectsPage:
 		layout = load_pygitweb_layout()
 		self._projectroot_label = str(layout.projectroot)
 		self._projects = list_projects(layout)
-		self._cursor = 0
-		self._scroll_offset = 0
+		self._list_state.select(0)
 		self._error = None
 
 	def status_text(self) -> str:
@@ -45,25 +37,25 @@ class ProjectsPage:
 			return "No projects found — Esc back"
 		return "Enter open project — j/k move — Esc back"
 
-	def draw(self, stdscr: int, height: int, width: int) -> None:
-		del stdscr
-		if height <= 0:
+	def draw(self, frame: Frame, area: Rect) -> None:
+		if area.height <= 0:
 			return
+		theme = current_theme()
+		chunks = (
+			Layout()
+			.direction(Direction.Vertical)
+			.constraints([Constraint.length(1), Constraint.length(1), Constraint.fill(1)])
+			.split(area)
+		)
 		title = f"Projects ({self._projectroot_label})"
-		draw_line(0, 0, title[:width], width, A_BOLD)
-		name_width = max(8, width - _BRANCH_WIDTH - 2)
+		frame.render_widget(Paragraph.from_string(title).style(theme.header), chunks[0])
+		name_width = max(8, chunks[1].width - _BRANCH_WIDTH - 2)
 		header = f"{'Name':<{name_width}}  {'Branch':<{_BRANCH_WIDTH}}"
-		draw_line(1, 0, header[:width], width, A_BOLD)
+		frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[1])
 
-		list_height = height - _TABLE_TOP
-		if list_height <= 0:
-			return
-		self._ensure_cursor_visible(list_height)
-		for view_row in range(list_height):
-			index = self._scroll_offset + view_row
-			if index >= len(self._projects):
-				break
-			project = self._projects[index]
+		items: list[ListItem] = []
+		selected = self._list_state.selected or 0
+		for index, project in enumerate(self._projects):
 			name = project.path
 			if len(name) > name_width:
 				name = name[: max(0, name_width - 1)] + "…"
@@ -71,36 +63,37 @@ class ProjectsPage:
 			if len(branch) > _BRANCH_WIDTH:
 				branch = branch[: max(0, _BRANCH_WIDTH - 1)] + "…"
 			line = f"{name:<{name_width}}  {branch:<{_BRANCH_WIDTH}}"
-			attr = A_REVERSE if index == self._cursor else 0
-			draw_line(_TABLE_TOP + view_row, 0, line[:width], width, attr)
+			style = focus_style(theme.normal) if index == selected else theme.normal
+			items.append(ListItem(line, style))
+		if not items:
+			items.append(ListItem("No projects", theme.log_date))
+		frame.render_stateful_list(
+			List(items).highlight_style(theme.focus).highlight_symbol(""),
+			chunks[2],
+			self._list_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 self._error is not None:
 			self._error = None
 		if not self._projects:
 			return PageResult()
 
-		if key in (KEY_UP, ord("k"), ord("K")):
-			self._cursor = max(0, self._cursor - 1)
+		if is_up(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(max(0, selected - 1))
 			return PageResult()
-		if key in (KEY_DOWN, ord("j"), ord("J")):
-			self._cursor = min(len(self._projects) - 1, self._cursor + 1)
+		if is_down(key):
+			selected = self._list_state.selected or 0
+			self._list_state.select(min(len(self._projects) - 1, selected + 1))
 			return PageResult()
-		if key in (KEY_ENTER, 10, 13):
-			project = self._projects[self._cursor]
+		if is_enter(key):
+			project = self._projects[self._list_state.selected or 0]
 			return PageResult(action=PageAction.POP, switch_repo=project.worktree)
 		return PageResult()
 
-	def _ensure_cursor_visible(self, list_height: int) -> None:
-		if self._cursor < self._scroll_offset:
-			self._scroll_offset = self._cursor
-		elif self._cursor >= self._scroll_offset + list_height:
-			self._scroll_offset = self._cursor - list_height + 1
-
 
 def projects_page() -> ProjectsPage:
 	return ProjectsPage()
diff --git a/pygittools/tui/pages/task_editor.py b/pygittools/tui/pages/task_editor.py
index 7c9d1b3..fad4c2e 100644
--- a/pygittools/tui/pages/task_editor.py
+++ b/pygittools/tui/pages/task_editor.py
@@ -7,6 +7,19 @@ from datetime import datetime
 from typing import Literal
 
 from pygit2 import Oid
+from pyratatui import (
+	Constraint,
+	Direction,
+	Frame,
+	Layout,
+	List,
+	ListItem,
+	ListState,
+	Paragraph,
+	Rect,
+	TextPrompt,
+	TextState,
+)
 
 from pygittools.tags_sync import delete_task_remote, push_board_tags
 from pygittools.tasks import (
@@ -19,21 +32,14 @@ from pygittools.tasks import (
 	get_task,
 )
 from pygittools.tasks_query import BOARD_STATUS_ORDER, get_task_view
-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.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
 from pygittools.tui.types import PageAction, PageContext, PageResult
-from pygittools.tui.ucurses import (  # type: ignore[import-untyped]
-	A_BOLD,
-	KEY_BACKSPACE,
-	KEY_DOWN,
-	KEY_ENTER,
-	KEY_RESIZE,
-	KEY_UP,
-)
 
 _FieldName = Literal["title", "description", "status", "priority", "assignee", "due_date"]
 _FIELDS: tuple[_FieldName, ...] = ("title", "description", "status", "priority", "assignee", "due_date")
 _PRIORITY_ORDER: tuple[str, ...] = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
+_TEXT_FIELDS = frozenset({"title", "description", "assignee", "due_date"})
 
 
 @dataclass(frozen=True, slots=True)
@@ -62,8 +68,10 @@ class TaskEditorPage:
 			assignee="",
 			due_date="",
 		)
-		self._field_index = 0
+		self._list_state = ListState()
+		self._list_state.select(0)
 		self._input_mode: Literal["nav", "input"] = "nav"
+		self._text_state = TextState()
 		self._error: str | None = None
 
 	def on_enter(self, ctx: PageContext) -> None:
@@ -83,126 +91,154 @@ class TaskEditorPage:
 	def status_text(self) -> str:
 		if self._error:
 			return self._error
+		field_index = self._list_state.selected or 0
 		if self._task_ref is not None:
-			if self._field_index == len(_FIELDS):
+			if field_index == len(_FIELDS):
 				return "Enter remove task (local) — j/k move — q or Esc back"
-			if self._field_index == len(_FIELDS) + 1:
+			if field_index == len(_FIELDS) + 1:
 				return "Enter remove task (remote & local) — j/k move — q or Esc back"
 		if self._input_mode == "input":
-			field = _FIELDS[self._field_index]
+			field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
 			if field in ("status", "priority"):
 				return "j/k change value — Enter save — Esc stop editing — q back"
 			return "Type to edit — Enter save — Esc stop editing — q back"
 		return "Enter edit field — j/k move field — q or Esc 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:
 			return
 		theme = current_theme()
+		chunks = (
+			Layout().direction(Direction.Vertical).constraints([Constraint.length(1), Constraint.fill(1)]).split(area)
+		)
 		header = f"Task in board {self._board_name}"
-		draw_line(0, 0, header[:width], width, theme.header)
-		if height <= 1:
+		frame.render_widget(Paragraph.from_string(header).style(theme.header), chunks[0])
+
+		field_index = self._list_state.selected or 0
+		if self._input_mode == "input" and field_index < len(_FIELDS) and _FIELDS[field_index] in _TEXT_FIELDS:
+			field = _FIELDS[field_index]
+			self._text_state.focus()
+			frame.render_text_prompt(TextPrompt(f"{field.replace('_', ' ').title()}: "), chunks[1], self._text_state)
 			return
 
-		rows = [
-			f"Title: {self._state.title}",
-			f"Description: {self._state.description}",
-			f"Status: {self._state.status or '(none)'}",
-			f"Priority: {self._state.priority or '(none)'}",
-			f"Assignee: {self._state.assignee}",
-			f"Due date (ISO): {self._state.due_date}",
-		]
-		if self._task_ref is not None:
-			rows.append("Remove task")
-			rows.append("Remove task (REMOTE & LOCAL)")
+		rows = self._row_labels()
+		items: list[ListItem] = []
 		for index, text in enumerate(rows):
-			if index + 1 >= height:
-				break
-			attr = theme.unstaged if self._task_ref is not None and index >= len(_FIELDS) else theme.normal
-			if index == self._field_index:
-				attr = focus_attr(attr)
+			style = theme.unstaged if self._task_ref is not None and index >= len(_FIELDS) else theme.normal
+			if index == field_index:
+				style = focus_style(style)
 				if self._input_mode == "input" and index < len(_FIELDS):
-					attr |= A_BOLD
-			draw_line(index + 1, 0, text[:width], width, attr)
+					style = style.bold()
+			items.append(ListItem(text, style))
+		frame.render_stateful_list(
+			List(items).highlight_style(theme.focus).highlight_symbol(""),
+			chunks[1],
+			self._list_state,
+		)
 
-	def handle_key(self, key: int) -> PageResult:
-		if key in (ord("q"), ord("Q")):
+	def handle_key(self, key: KeyLike) -> PageResult:
+		if is_quit(key):
 			return PageResult(action=PageAction.POP)
-		if key == 27:
+		if is_esc(key):
 			if self._input_mode == "input":
+				self._sync_text_field_from_prompt()
 				self._input_mode = "nav"
 				return PageResult()
 			return PageResult(action=PageAction.POP)
-		if key == KEY_RESIZE:
-			return PageResult()
 		if self._error is not None:
 			self._error = None
 
-		field = _FIELDS[self._field_index] if self._field_index < len(_FIELDS) else "title"
+		field_index = self._list_state.selected or 0
+		field = _FIELDS[field_index] if field_index < len(_FIELDS) else "title"
 
 		if self._input_mode == "input":
-			if field in ("title", "description", "assignee", "due_date"):
-				if key in (KEY_UP, KEY_DOWN):
-					if key == KEY_UP:
-						self._field_index = max(0, self._field_index - 1)
-					else:
-						self._field_index = min(len(_FIELDS) - 1, self._field_index + 1)
+			if field in _TEXT_FIELDS:
+				if is_up(key):
+					self._sync_text_field_from_prompt()
+					self._list_state.select(max(0, field_index - 1))
+					self._begin_field_input()
 					return PageResult()
-				if key in (KEY_ENTER, 10, 13):
+				if is_down(key):
+					self._sync_text_field_from_prompt()
+					self._list_state.select(min(len(_FIELDS) - 1, field_index + 1))
+					self._begin_field_input()
+					return PageResult()
+				if is_enter(key):
+					self._sync_text_field_from_prompt()
 					return self._save()
-				return self._edit_text_field(field, key)
+				self._text_state.focus()
+				self._text_state.handle_key(key)
+				return PageResult()
 			if field == "status":
-				if key in (KEY_ENTER, 10, 13):
+				if is_enter(key):
 					return self._save()
 				self._cycle_status(key)
 				return PageResult()
 			if field == "priority":
-				if key in (KEY_ENTER, 10, 13):
+				if is_enter(key):
 					return self._save()
 				self._cycle_priority(key)
 				return PageResult()
 			return PageResult()
 
-		if key in (KEY_UP, ord("k"), ord("K")):
-			self._field_index = max(0, self._field_index - 1)
+		if is_up(key):
+			self._list_state.select(max(0, field_index - 1))
 			return PageResult()
-		if key in (KEY_DOWN, ord("j"), ord("J")):
+		if is_down(key):
 			max_index = (len(_FIELDS) + 1) if self._task_ref is not None else len(_FIELDS) - 1
-			self._field_index = min(max_index, self._field_index + 1)
+			self._list_state.select(min(max_index, field_index + 1))
 			return PageResult()
-		if key in (KEY_ENTER, 10, 13):
+		if is_enter(key):
 			if self._task_ref is not None:
-				if self._field_index == len(_FIELDS):
+				if field_index == len(_FIELDS):
 					return self._delete_local()
-				if self._field_index == len(_FIELDS) + 1:
+				if field_index == len(_FIELDS) + 1:
 					return self._delete_remote_local()
 			self._input_mode = "input"
+			self._begin_field_input()
 			return PageResult()
-		if key in (KEY_BACKSPACE, 127, 8) and field in ("title", "description", "assignee", "due_date"):
-			self._input_mode = "input"
-			return self._edit_text_field(field, key)
 		return PageResult()
 
-	def _edit_text_field(self, field: _FieldName, key: int) -> PageResult:
-		if key in (KEY_BACKSPACE, 127, 8):
-			self._state = _replace_state_field(self._state, field, _text_for_field(self._state, field)[:-1])
-			return PageResult()
-		if 32 <= key <= 126:
-			text = _text_for_field(self._state, field) + chr(key)
-			self._state = _replace_state_field(self._state, field, text)
-			return PageResult()
-		return PageResult()
+	def _row_labels(self) -> list[str]:
+		rows = [
+			f"Title: {self._state.title}",
+			f"Description: {self._state.description}",
+			f"Status: {self._state.status or '(none)'}",
+			f"Priority: {self._state.priority or '(none)'}",
+			f"Assignee: {self._state.assignee}",
+			f"Due date (ISO): {self._state.due_date}",
+		]
+		if self._task_ref is not None:
+			rows.append("Remove task")
+			rows.append("Remove task (REMOTE & LOCAL)")
+		return rows
 
-	def _cycle_status(self, key: int) -> None:
-		if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
+	def _begin_field_input(self) -> None:
+		field_index = self._list_state.selected or 0
+		if field_index >= len(_FIELDS):
+			return
+		field = _FIELDS[field_index]
+		if field in _TEXT_FIELDS:
+			self._text_state.set_value(_text_for_field(self._state, field))
+			self._text_state.focus()
+
+	def _sync_text_field_from_prompt(self) -> None:
+		field_index = self._list_state.selected or 0
+		if field_index >= len(_FIELDS):
+			return
+		field = _FIELDS[field_index]
+		if field in _TEXT_FIELDS:
+			self._state = _replace_state_field(self._state, field, self._text_state.value())
+
+	def _cycle_status(self, key: KeyLike) -> None:
+		if not (is_up(key) or is_down(key)):
 			return
 		current = self._state.status or self._default_status or BOARD_STATUS_ORDER[0]
 		try:
 			index = BOARD_STATUS_ORDER.index(current)
 		except ValueError:
 			index = 0
-		if key in (KEY_UP, ord("k"), ord("K")):
+		if is_up(key):
 			index = (index - 1) % len(BOARD_STATUS_ORDER)
 		else:
 			index = (index + 1) % len(BOARD_STATUS_ORDER)
@@ -215,8 +251,8 @@ class TaskEditorPage:
 			due_date=self._state.due_date,
 		)
 
-	def _cycle_priority(self, key: int) -> None:
-		if key not in (KEY_UP, KEY_DOWN, ord("j"), ord("J"), ord("k"), ord("K")):
+	def _cycle_priority(self, key: KeyLike) -> None:
+		if not (is_up(key) or is_down(key)):
 			return
 		if not _PRIORITY_ORDER:
 			return
@@ -225,7 +261,7 @@ class TaskEditorPage:
 			index = _PRIORITY_ORDER.index(current)
 		except ValueError:
 			index = 0
-		if key in (KEY_UP, ord("k"), ord("K")):
+		if is_up(key):
 			index = (index - 1) % len(_PRIORITY_ORDER)
 		else:
 			index = (index + 1) % len(_PRIORITY_ORDER)
@@ -275,9 +311,7 @@ class TaskEditorPage:
 		ctx = self._ctx
 		if ctx is None or self._task_ref is None:
 			return PageResult(action=PageAction.POP)
-
 		delete_task_local(ctx.repo, self._task_ref)
-
 		return PageResult(action=PageAction.POP)
 
 	def _delete_remote_local(self) -> PageResult:
@@ -286,15 +320,11 @@ class TaskEditorPage:
 			return PageResult(action=PageAction.POP)
 
 		remote_name = _default_remote_name(ctx)
-
 		if remote_name is not None:
 			delete_task_remote(ctx.repo, remote_name, self._task_ref)
-
 		delete_task_local(ctx.repo, self._task_ref)
-
 		if remote_name is not None:
 			push_board_tags(ctx.repo, remote_name, self._board_name)
-
 		return PageResult(action=PageAction.POP)
 
 
diff --git a/pygittools/tui/pages_test.py b/pygittools/tui/pages_test.py
index 965b430..1c86f0f 100644
--- a/pygittools/tui/pages_test.py
+++ b/pygittools/tui/pages_test.py
@@ -17,14 +17,15 @@ from pygittools.tui.pages.no_repo import NoRepoPage
 from pygittools.tui.pages.projects import ProjectsPage, projects_page
 from pygittools.tui.pages.task_editor import TaskEditorPage
 from pygittools.tui.types import PageAction, PageContext
-from pygittools.tui.ucurses import KEY_BACKSPACE, KEY_DOWN, KEY_ENTER, KEY_RESIZE, KEY_UP
+from pyratatui import Rect
+from pygittools.tui.keys import key
 
 
 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"))
+	result = page.handle_key(key("q"))
 	assert result.action == PageAction.QUIT
 
 
@@ -32,27 +33,27 @@ 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.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_active_tab("log")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_active_tab("tasks")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_header_focus("repo")
-	page.draw(0, 20, 80)
-	page.handle_key(KEY_RESIZE)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	page.handle_key(key("Null"))
 
 
 def test_home_page_switch_to_input(page_ctx: PageContext) -> None:
 	page = home_page()
 	page.on_enter(page_ctx)
-	page.handle_key(ord("a"))
+	page.handle_key(key("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("?"))
+	result = page.handle_key(key("?"))
 	assert result.action == PageAction.PUSH
 	assert isinstance(result.next_page, HelpPage)
 
@@ -63,11 +64,11 @@ def test_home_page_draws_detached_head(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.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert current_branch_name(committed_repo) == "(detached)"
 	assert "j/k move" in page.status_text()
 	page.set_active_tab("log")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert "No commits" not in page.status_text()
 
 
@@ -78,91 +79,91 @@ def test_empty_repo_can_create_branch_and_commit(repo: Repository) -> None:
 	page = home_page()
 	assert isinstance(page, HomePage)
 	page.on_enter(ctx)
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_active_tab("log")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert page.status_text() == "No commits — j/k move"
 	page.set_active_tab("status")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
-	page.handle_key(KEY_UP)
-	opened = page.handle_key(KEY_ENTER)
+	page.handle_key(key("Up"))
+	opened = page.handle_key(key("Enter"))
 	assert opened.action == PageAction.PUSH
 	assert isinstance(opened.next_page, BranchPickerPage)
 	picker = opened.next_page
 	picker.on_enter(ctx)
-	picker.draw(0, 20, 80)
+	picker.draw(MagicMock(), Rect(0, 0, 80, 20))
 	for char in "feature":
-		picker.handle_key(ord(char))
-	created = picker.handle_key(KEY_ENTER)
+		picker.handle_key(key(char))
+	created = picker.handle_key(key("Enter"))
 	assert created.action == PageAction.POP
 	assert current_branch_name(repo) == "feature"
 
 	workdir = repo.workdir
 	assert workdir is not None
 	(Path(workdir) / "hello.txt").write_text("hello\n", encoding="utf-8")
-	page.draw(0, 20, 80)
-	page.handle_key(KEY_DOWN)
-	_press_until(page, KEY_DOWN, lambda hint: hint.startswith("Enter stage —"))
-	page.handle_key(KEY_ENTER)
-	_press_until(page, KEY_UP, lambda hint: "type message" in hint.casefold())
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
+	page.handle_key(key("Down"))
+	_press_until(page, key("Down"), lambda hint: hint.startswith("Enter stage —"))
+	page.handle_key(key("Enter"))
+	_press_until(page, key("Up"), lambda hint: "type message" in hint.casefold())
 	for char in "init":
-		page.handle_key(ord(char))
-	page.handle_key(KEY_ENTER)
+		page.handle_key(key(char))
+	page.handle_key(key("Enter"))
 
 	assert not repo.head_is_unborn, page.changes.status_hint()
 	assert current_branch_name(repo) == "feature"
 	assert "feature" in repo.branches.local
 	assert repo.head.peel(Commit).message.splitlines()[0] == "init"
 	page.set_active_tab("log")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert "No commits" not in page.status_text()
 
 
-def _press_until(page: HomePage, key: int, ready: Callable[[str], bool]) -> None:
+def _press_until(page: HomePage, press: object, ready: Callable[[str], bool]) -> None:
 	for _ in range(12):
 		hint = page.status_text()
 		if ready(hint):
 			return
-		page.handle_key(key)
+		page.handle_key(press)  # type: ignore[arg-type]
 	raise AssertionError(page.status_text())
 
 
 def test_home_page_draw_without_context() -> None:
 	page = home_page()
-	page.draw(0, 10, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 10))
 
 
 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)
+	page.draw(MagicMock(), Rect(0, 0, 80, 10))
+	assert page.handle_key(key("q")).action == PageAction.QUIT
+	assert page.handle_key(key("h")).action == PageAction.PUSH
+	page.handle_key(key("Null"))
 
 
 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)
+	page.draw(MagicMock(), Rect(0, 0, 80, 5))
+	page.handle_key(key("Down"))
+	page.handle_key(key("Up"))
+	assert page.handle_key(key("q")).action == PageAction.POP
+	page.handle_key(key("Null"))
 
 
 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
+	page.draw(MagicMock(), Rect(0, 0, 80, 10))
+	page.handle_key(key("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(key("q")).action == PageAction.POP
 
 
 def test_branch_picker_checkout_branch(committed_repo: Repository) -> None:
@@ -171,19 +172,19 @@ def test_branch_picker_checkout_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(KEY_DOWN)
-	page.handle_key(KEY_ENTER)
-	assert page.handle_key(KEY_ENTER).action == PageAction.POP
+	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)
+	page.handle_key(key("n"))
+	page.handle_key(key("e"))
+	page.handle_key(key("w"))
+	page.handle_key(key("Enter"))
 	assert "new" in committed_repo.branches.local
 
 
@@ -191,14 +192,14 @@ def test_branch_picker_create_invalid_shows_error(committed_repo: Repository) ->
 	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)
+	page.handle_key(key(" "))
+	page.handle_key(key("Enter"))
 	assert page.status_text()
 
 
 def test_branch_picker_draw_zero_height() -> None:
 	page = BranchPickerPage()
-	page.draw(0, 0, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 0))
 
 
 def test_projects_page_lists_repos(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -221,9 +222,9 @@ def test_projects_page_lists_repos(tmp_path: Path, monkeypatch: pytest.MonkeyPat
 	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)
+	page.draw(MagicMock(), Rect(0, 0, 80, 10))
+	page.handle_key(key("Down"))
+	result = page.handle_key(key("Enter"))
 	assert result.switch_repo == project.resolve()
 
 
@@ -234,11 +235,11 @@ def test_projects_page_empty(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) ->
 	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)
+	page.handle_key(key("Enter"))
 
 
 def test_projects_page_draw_zero_height() -> None:
-	ProjectsPage().draw(0, 0, 80)
+	ProjectsPage().draw(MagicMock(), Rect(0, 0, 80, 0))
 
 
 def _empty_tree() -> Oid:
@@ -265,12 +266,12 @@ def test_home_page_tasks_tab_creates_and_edits_tasks(committed_repo: Repository)
 	page = home_page()
 	page.on_enter(ctx)
 	page.set_active_tab("tasks")
-	page.draw(0, 20, 80)
+	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
 	# Move to first status header and then to (+ New Task)
-	page.handle_key(KEY_DOWN)
-	page.handle_key(KEY_DOWN)
-	result_new = page.handle_key(KEY_ENTER)
+	page.handle_key(key("Down"))
+	page.handle_key(key("Down"))
+	result_new = page.handle_key(key("Enter"))
 	assert result_new.action == PageAction.PUSH
 	assert isinstance(result_new.next_page, TaskEditorPage)
 
@@ -278,9 +279,9 @@ def test_home_page_tasks_tab_creates_and_edits_tasks(committed_repo: Repository)
 	page = home_page()
 	page.on_enter(ctx)
 	page.set_active_tab("tasks")
-	page.handle_key(KEY_DOWN)
-	page.handle_key(KEY_DOWN)
-	page.handle_key(KEY_DOWN)
-	result_existing = page.handle_key(KEY_ENTER)
+	page.handle_key(key("Down"))
+	page.handle_key(key("Down"))
+	page.handle_key(key("Down"))
+	result_existing = page.handle_key(key("Enter"))
 	assert result_existing.action == PageAction.PUSH
 	assert isinstance(result_existing.next_page, TaskEditorPage)
