diff --git a/pygittools/tui/changes_list.py b/pygittools/tui/changes_list.py
index 7234620..c212088 100644
--- a/pygittools/tui/changes_list.py
+++ b/pygittools/tui/changes_list.py
@@ -45,6 +45,18 @@ class _Row:
 	label: str
 
 
+@dataclass(frozen=True, slots=True)
+class NavKeyResult:
+	enter_input: bool = False
+	layout_changed: bool = False
+
+
+@dataclass(frozen=True, slots=True)
+class InputKeyResult:
+	layout_changed: bool = False
+	relinquish: bool = False
+
+
 _SECTION_ORDER: tuple[tuple[SectionId, str], ...] = (
 	(SectionId.STAGED, "Staged"),
 	(SectionId.UNSTAGED, "Unstaged"),
@@ -112,39 +124,66 @@ class ChangesList:
 			attr = A_REVERSE if focused and highlight else 0
 			draw_line(top_row + view_row, 0, label, width, attr)
 
-	def handle_key(self, key: int, repo: Repository) -> bool:
-		"""Handle a key press. Returns True when the list layout may have changed."""
+	def handle_nav_key(self, key: int, repo: Repository) -> NavKeyResult:
+		"""Handle navigation keys. May begin commit input when typing on the commit row."""
 		if key == KEY_RESIZE:
-			return False
-		if self._error is not None and key not in (KEY_RESIZE,):
+			return NavKeyResult()
+		if self._error is not None:
 			self._error = None
 
 		if key in (KEY_UP, ord("k"), ord("K")):
 			self._cursor = max(0, self._cursor - 1)
-			return False
+			return NavKeyResult()
 		if key in (KEY_DOWN, ord("j"), ord("J")):
 			self._cursor = min(max(0, len(self._rows) - 1), self._cursor + 1)
-			return False
+			return NavKeyResult()
 		if not self._rows:
-			return False
+			return NavKeyResult()
 
 		row = self._rows[self._cursor]
 		if row.kind == "commit":
-			return self._handle_commit_key(key, repo)
+			if self._try_begin_input(key):
+				return NavKeyResult(enter_input=True)
+			layout_changed = self._handle_commit_nav_key(key, repo)
+			return NavKeyResult(layout_changed=layout_changed)
 
 		if key in (KEY_LEFT, KEY_RIGHT, ord(" ")) and row.kind == "header" and row.section is not None:
 			self._collapsed[row.section] = not self._collapsed[row.section]
 			self.refresh(repo)
-			return True
+			return NavKeyResult(layout_changed=True)
 		if key in (KEY_ENTER, 10, 13) and row.kind == "header" and row.section is not None:
 			self._toggle_section(repo, row.section)
 			self.refresh(repo)
-			return True
+			return NavKeyResult(layout_changed=True)
 		if key in (KEY_ENTER, 10, 13) and row.kind == "file" and row.path is not None and row.section is not None:
 			self._toggle_stage(repo, row.section, row.path)
 			self.refresh(repo)
-			return True
-		return False
+			return NavKeyResult(layout_changed=True)
+		return NavKeyResult()
+
+	def handle_input_key(self, key: int, repo: Repository) -> InputKeyResult:
+		"""Handle keys while commit input context is active. Esc is handled by InputContext."""
+		if key == KEY_RESIZE:
+			return InputKeyResult()
+		if self._error is not None:
+			self._error = None
+		if key in (KEY_ENTER, 10, 13):
+			ok, error = commit_staged(repo, self._commit_message)
+			if ok:
+				self._commit_message = ""
+				self._error = None
+				self.refresh(repo)
+				return InputKeyResult(layout_changed=True, relinquish=True)
+			self._error = error
+			return InputKeyResult()
+		if key in (KEY_BACKSPACE, 127, 8):
+			if self._commit_message:
+				self._commit_message = self._commit_message[:-1]
+			return InputKeyResult()
+		if 32 <= key <= 126:
+			self._commit_message += chr(key)
+			return InputKeyResult()
+		return InputKeyResult()
 
 	def status_hint(self) -> str:
 		if self._error:
@@ -164,7 +203,18 @@ class ChangesList:
 			return "Enter unstage — j/k move"
 		return "Enter stage — j/k move"
 
-	def _handle_commit_key(self, key: int, repo: Repository) -> bool:
+	def _try_begin_input(self, key: int) -> bool:
+		if key in (KEY_BACKSPACE, 127, 8):
+			if not self._commit_message:
+				return False
+			self._commit_message = self._commit_message[:-1]
+			return True
+		if 32 <= key <= 126:
+			self._commit_message += chr(key)
+			return True
+		return False
+
+	def _handle_commit_nav_key(self, key: int, repo: Repository) -> bool:
 		if key in (KEY_ENTER, 10, 13):
 			ok, error = commit_staged(repo, self._commit_message)
 			if ok:
@@ -173,13 +223,6 @@ class ChangesList:
 				self.refresh(repo)
 				return True
 			self._error = error
-			return False
-		if key in (KEY_BACKSPACE, 127, 8):
-			self._commit_message = self._commit_message[:-1]
-			return False
-		if 32 <= key <= 126:
-			self._commit_message += chr(key)
-			return False
 		return False
 
 	def _commit_label(self, width: int, focused: bool) -> str:
diff --git a/pygittools/tui/input_context.py b/pygittools/tui/input_context.py
new file mode 100644
index 0000000..8818d6b
--- /dev/null
+++ b/pygittools/tui/input_context.py
@@ -0,0 +1,108 @@
+"""Keyboard input modes for TUI pages (navigation vs text entry)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal, Protocol
+
+from unicurses import (  # type: ignore[import-untyped]
+	KEY_DOWN,
+	KEY_ENTER,
+	KEY_UP,
+)
+
+from pygittools.tui.changes_list import ChangesList
+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
+
+_KEY_ESC = 27
+_HeaderFocus = Literal["changes", "repo", "branch"]
+
+
+@dataclass(frozen=True, slots=True)
+class ContextDispatch:
+	page_result: PageResult | None = None
+	next_mode: Literal["nav", "input"] | None = None
+
+
+class PageInputHost(Protocol):
+	@property
+	def header_focus(self) -> _HeaderFocus: ...
+
+	def set_header_focus(self, focus: _HeaderFocus) -> None: ...
+
+	@property
+	def page_context(self) -> PageContext | None: ...
+
+	@property
+	def changes(self) -> ChangesList: ...
+
+
+class NavContext:
+	def status_hint(self, host: PageInputHost) -> str:
+		if host.header_focus == "repo":
+			return "Enter browse projects — j/k move"
+		if host.header_focus == "branch":
+			return "Enter switch branch — j/k move"
+		return host.changes.status_hint()
+
+	def dispatch_key(self, host: PageInputHost, key: int) -> ContextDispatch:
+		if key in (ord("h"), ord("H")):
+			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")):
+				host.set_header_focus("branch")
+			elif key in (KEY_ENTER, 10, 13):
+				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")):
+				host.set_header_focus("repo")
+			elif key in (KEY_DOWN, ord("j"), ord("J")):
+				host.set_header_focus("changes")
+			elif key in (KEY_ENTER, 10, 13):
+				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():
+			host.set_header_focus("branch")
+			return ContextDispatch()
+
+		ctx = host.page_context
+		if ctx is None:
+			return ContextDispatch()
+		nav_result = host.changes.handle_nav_key(key, ctx.repo)
+		if nav_result.enter_input:
+			return ContextDispatch(next_mode="input")
+		return ContextDispatch()
+
+
+class InputContext:
+	def status_hint(self, host: PageInputHost) -> str:
+		del host
+		return "Esc relinquish input — Enter commit"
+
+	def dispatch_key(self, host: PageInputHost, key: int) -> ContextDispatch:
+		if key == _KEY_ESC:
+			return ContextDispatch(next_mode="nav")
+		ctx = host.page_context
+		if ctx is not None:
+			result = host.changes.handle_input_key(key, ctx.repo)
+			if result.relinquish:
+				return ContextDispatch(next_mode="nav")
+		return ContextDispatch()
+
+
+InputMode = NavContext | InputContext
+
+
+def nav_context() -> NavContext:
+	return NavContext()
+
+
+def input_context() -> InputContext:
+	return InputContext()
diff --git a/pygittools/tui/pages/home.py b/pygittools/tui/pages/home.py
index 0587000..5db222d 100644
--- a/pygittools/tui/pages/home.py
+++ b/pygittools/tui/pages/home.py
@@ -7,18 +7,13 @@ from typing import Literal
 from unicurses import (  # type: ignore[import-untyped]
 	A_BOLD,
 	A_REVERSE,
-	KEY_DOWN,
-	KEY_ENTER,
 	KEY_RESIZE,
-	KEY_UP,
 )
 
 from pygittools.tui.branches import current_branch_name
 from pygittools.tui.changes_list import ChangesList
 from pygittools.tui.draw import draw_line
-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.input_context import InputMode, input_context, nav_context
 from pygittools.tui.types import Page, PageAction, PageContext, PageResult
 
 _HEADER_ROWS = 5
@@ -34,18 +29,31 @@ class HomePage:
 		self._ctx: PageContext | None = None
 		self._changes = ChangesList()
 		self._header_focus: _HeaderFocus = "changes"
+		self._input_mode: InputMode = nav_context()
+
+	@property
+	def header_focus(self) -> _HeaderFocus:
+		return self._header_focus
+
+	def set_header_focus(self, focus: _HeaderFocus) -> None:
+		self._header_focus = focus
+
+	@property
+	def page_context(self) -> PageContext | None:
+		return self._ctx
+
+	@property
+	def changes(self) -> ChangesList:
+		return self._changes
 
 	def on_enter(self, ctx: PageContext) -> None:
 		self._ctx = ctx
 		self._header_focus = "changes"
+		self._input_mode = nav_context()
 		self._changes.refresh(ctx.repo)
 
 	def status_text(self) -> str:
-		if self._header_focus == "repo":
-			return "Enter browse projects — j/k move"
-		if self._header_focus == "branch":
-			return "Enter switch branch — j/k move"
-		return self._changes.status_hint()
+		return self._input_mode.status_hint(self)
 
 	def draw(self, stdscr: int, height: int, width: int) -> None:
 		del stdscr
@@ -79,34 +87,16 @@ class HomePage:
 	def handle_key(self, key: int) -> PageResult:
 		if key in (ord("q"), ord("Q")):
 			return PageResult(action=PageAction.QUIT)
-		if key in (ord("h"), ord("H")):
-			return PageResult(action=PageAction.PUSH, next_page=help_page())
 		if key == KEY_RESIZE:
 			return PageResult()
 
-		if self._header_focus == "repo":
-			if key in (KEY_DOWN, ord("j"), ord("J")):
-				self._header_focus = "branch"
-			elif key in (KEY_ENTER, 10, 13):
-				return PageResult(action=PageAction.PUSH, next_page=projects_page())
-			return PageResult()
-
-		if self._header_focus == "branch":
-			if key in (KEY_UP, ord("k"), ord("K")):
-				self._header_focus = "repo"
-			elif key in (KEY_DOWN, ord("j"), ord("J")):
-				self._header_focus = "changes"
-			elif key in (KEY_ENTER, 10, 13):
-				return PageResult(action=PageAction.PUSH, next_page=branch_picker_page())
-			return PageResult()
-
-		if key in (KEY_UP, ord("k"), ord("K")) and self._changes.is_at_top():
-			self._header_focus = "branch"
-			return PageResult()
-
-		ctx = self._ctx
-		if ctx is not None:
-			self._changes.handle_key(key, ctx.repo)
+		dispatch = self._input_mode.dispatch_key(self, key)
+		if dispatch.next_mode == "input":
+			self._input_mode = input_context()
+		elif dispatch.next_mode == "nav":
+			self._input_mode = nav_context()
+		if dispatch.page_result is not None:
+			return dispatch.page_result
 		return PageResult()
 
 
