diff --git a/pygittools/README.md b/pygittools/README.md
index 2f30e25..0421368 100644
--- a/pygittools/README.md
+++ b/pygittools/README.md
@@ -4,7 +4,7 @@ Hooks, tooling, and pygit2 workflows used by PyGitWeb. Can be used headless for 
 
 ## PGT
 
-`pgt` is a terminal UI for commits, branches, tasks, and worktrees. The home page has **Status**, **Log**, **Tasks**, and **Worktrees** tabs. `H` / `L` switch tabs, `J` / `K` move the selection, `Q` or `Ctrl-C` quits, and `?` opens help. On **Status**, `!` or the Actions row runs project scripts from `pyproject.toml` or `actions.toml`. Full key bindings are in the in-app help (`src/pygittools/help.md`).
+`pgt` is a terminal UI for commits, branches, tasks, and worktrees. The home page has **Status**, **Log**, **Tasks**, and **Worktrees** tabs. Arrow keys move the selection and switch tabs; `Q` or `Ctrl-C` quits. Press `/` for slash commands (`/help`, `/actions`, `/commit`, `/vim`, …). On **Status**, `/actions` or the Actions row runs project scripts from `pyproject.toml` or `actions.toml`. Full key bindings are in the in-app help (`src/pygittools/help.md`).
 
 To run from the repo root, use `uv run pgt`.
 
diff --git a/pygittools/src/pygittools/help.md b/pygittools/src/pygittools/help.md
index 97a9753..73eca75 100644
--- a/pygittools/src/pygittools/help.md
+++ b/pygittools/src/pygittools/help.md
@@ -2,23 +2,37 @@
 
 PyGitTools terminal UI for browsing a local git repository.
 
+## Modes
+
+Navigation is arrow-key based by default. Press `/` to open the command prompt, then type a command and **Enter**.
+
+- **/help** — open this help page
+- **/actions** — open the actions page
+- **/quit** — quit
+- **/commit** — edit the commit message
+- **/status** **/log** **/tasks** **/worktrees** — switch home tabs
+- **/projects** — open the projects table
+- **/branch** — open the branch picker
+- **/vim** — enable vim-style **H**/**J**/**K**/**L** navigation
+- **/novim** (or **/arrows**) — restore arrow-only navigation (default)
+
+**Esc** cancels the command prompt. **Q** or **Ctrl-C** still quits from navigation.
+
 ## Home page
 
 The top line shows the project name followed by **Status**, **Log**, **Tasks**, and **Worktrees** tabs. The active tab is highlighted in yellow.
 
-- **H** / **Left** or **L** / **Right** — previous or next tab (anywhere except while typing a commit message)
-- **K** / **Up** — move selection up (from the changes list, to the actions row, then the branch row, then the project row)
-- **J** / **Down** — move selection down
+- **Left** / **Right** — previous or next tab (anywhere except while typing a commit message or slash command)
+- **Up** — move selection up (from the changes list, to the actions row, then the branch row, then the project row)
+- **Down** — move selection down
 - **Project row** — **Enter** opens the projects table (PyGitWeb `PROJECTROOT`)
 - **Branch row** — **Enter** opens a filterable list of local branches to check out. On **Tasks**, **Enter** syncs the current board with the default remote. On **Worktrees**, **Enter** opens the add-worktree form.
 - **Actions row** — always on **Status**. **Enter** opens the actions page when the project has action sets; otherwise the row reads **No Action Groups**
-- **Commit row** — type a message, then **Enter** to commit staged changes (runs git hooks); **Enter** with an empty message starts typing
+- **Commit row** — **Enter** opens the multiline commit editor (in **/vim** mode, **i** also works). **/commit** does the same. In the editor, **Enter** inserts a newline; **Tab** / **↓** move to the **[ Commit ]** and **[ Commit (amend) ]** buttons; **Enter** on a button creates or amends the commit (runs git hooks). **Esc** returns to navigation.
 - **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)
-- **Log tab** — **J** / **K** scroll the commit history (newest first)
-- **?** — open this help page
-- **!** — open the actions page (scripts from ``pyproject.toml`` or ``actions.toml``)
+- **Log tab** — **Up** / **Down** scroll the commit history (newest first)
 - **Q** or **Ctrl-C** — quit
 
 Commit or hook failures appear in the status bar.
@@ -37,9 +51,8 @@ project's ``.venv``. An ``actions.toml`` set looks like this:
       { name = "lint", script = "scripts/lint.py" },
     ]
 
-- **K** / **Up** — move selection up
-- **J** / **Down** — move selection down
-- **H** / **Left** or **L** / **Right** — previous or next action set
+- **Up** / **Down** — move selection
+- **Left** / **Right** — previous or next action set
 - **Tab** / **Shift-Tab** — next or previous action set
 - **Enter** — run the selected action (or **Run all**)
 - **A** — run all actions in the current set, stopping at the first failure
@@ -51,8 +64,7 @@ staged (success) or unstaged (failure) theme colors.
 ## Branch picker
 
 - **Type** — filter branches by name
-- **K** / **Up** — move selection up
-- **J** / **Down** — move selection down
+- **Up** / **Down** — move selection
 - **Enter** — check out the selected branch (`*` marks current), or create a new branch from the current branch when the filter matches nothing
 - **Esc** / **Q** — return to the home page
 
@@ -65,8 +77,7 @@ Lists git repositories under the PyGitWeb project directory. Settings are read f
 `PYGITWEB_PROJECTROOT`, `PYGITWEB_PROJECTS_LIST`, `PYGITWEB_PROJECT_MAXDEPTH`, and
 `PYGITWEB_LIST_ALL`.
 
-- **K** / **Up** — move selection up
-- **J** / **Down** — move selection down
+- **Up** / **Down** — move selection
 - **Enter** — open the selected project and return to the home page
 - **Esc** / **Q** — return without switching
 
@@ -74,7 +85,8 @@ Lists git repositories under the PyGitWeb project directory. Settings are read f
 
 Lists boards stored in the repository. The board row shows the current board and its task count. Each status is a collapsible section with a **(+ New Task)** row.
 
-- **J** / **K** — move the selection
+- **Up** / **Down** — move the selection
+- **Left** / **Right** on the board row — switch boards
 - **Enter** on a status header, or **Space** — collapse or expand that status
 - **Enter** on **(+ New Task)** — open the task editor for a new task
 - **Enter** on a task — open the task editor
@@ -84,7 +96,7 @@ Lists boards stored in the repository. The board row shows the current board and
 
 Lists linked worktrees. The active worktree is marked with `*`.
 
-- **J** / **K** — move the selection
+- **Up** / **Down** — move the selection
 - **Enter** on **(+ Add worktree)**, or **Enter** on the branch row — open the add-worktree form
 - **Enter** on another worktree — switch the TUI to that worktree
 - **D** — remove the selected worktree. The active worktree cannot be removed.
@@ -92,14 +104,13 @@ Lists linked worktrees. The active worktree is marked with `*`.
 ## Add worktree
 
 - **Type** — path, then an optional branch. Leave the branch empty and git creates a new branch named after the directory, based on `HEAD`. An existing local branch is checked out as-is; an unknown name is created with `-b`.
-- **J** / **K** — switch between the path and branch fields
+- **Up** / **Down** — switch between the path and branch fields
 - **Enter** — create the worktree. A relative path is resolved from the current worktree.
 - **Esc** / **Q** — return to the home page
 
 ## Help page
 
-- **Up** / **K** — scroll up
-- **Down** / **J** — scroll down
+- **Up** / **Down** — scroll
 - **Q** / **Esc** — return to the previous page
 
 ## Command line
diff --git a/pygittools/src/pygittools/tui/app.py b/pygittools/src/pygittools/tui/app.py
index 7fae440..66f1777 100644
--- a/pygittools/src/pygittools/tui/app.py
+++ b/pygittools/src/pygittools/tui/app.py
@@ -7,6 +7,7 @@ from pathlib import Path
 from pyratatui import Frame, Terminal
 
 from pygittools.tui.git_colors import init_tui_color_theme, status_bar_style
+from pygittools.tui.keys import set_vim_enabled
 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
@@ -20,6 +21,7 @@ def run_tui(repo_path: Path | None = None) -> int:
 	ctx = try_open_page_context(start)
 	initial: Page = home_page() if ctx is not None else NoRepoPage(start)
 	stack: list[Page] = [initial]
+	set_vim_enabled(False)
 
 	with Terminal() as term:
 		term.hide_cursor()
diff --git a/pygittools/src/pygittools/tui/changes_list.py b/pygittools/src/pygittools/tui/changes_list.py
index b8bffba..73eabbe 100644
--- a/pygittools/src/pygittools/tui/changes_list.py
+++ b/pygittools/src/pygittools/tui/changes_list.py
@@ -13,28 +13,33 @@ from pyratatui import (
 	Direction,
 	Frame,
 	Layout,
+	Line,
 	List,
 	ListItem,
 	ListState,
+	Paragraph,
 	Rect,
+	Span,
 	Style,
-	TextPrompt,
-	TextState,
+	Text,
 )
 
 from pygittools.tui.commit_draft import save_commit_draft
 from pygittools.tui.git_colors import current_theme, focus_style, row_style_for_section
 from pygittools.tui.keys import (
 	KeyLike,
-	is_backspace,
+	is_backtab,
 	is_down,
 	is_enter,
 	is_left,
 	is_right,
 	is_space,
+	is_tab,
 	is_up,
-	printable_char,
+	move_hint,
+	vim_enabled,
 )
+from pygittools.tui.multiline_edit import MultilineEdit
 from pygittools.tui.worktree import (
 	categorize_status,
 	commit_staged,
@@ -44,6 +49,10 @@ from pygittools.tui.worktree import (
 	unstage_paths,
 )
 
+_InputFocus = Literal["message", "commit", "amend"]
+_EDITOR_MIN_HEIGHT = 3
+_EDITOR_MAX_HEIGHT = 8
+
 
 class SectionId(Enum):
 	STAGED = "staged"
@@ -86,16 +95,18 @@ class ChangesList:
 		self._files: dict[SectionId, list[str]] = {section: [] for section in SectionId}
 		self._rows: list[_Row] = []
 		self._list_state = ListState()
-		self._commit_state = TextState()
+		self._commit_edit = MultilineEdit()
+		self._input_focus: _InputFocus = "message"
 		self._error: str | None = None
 		self._staged_count = 0
+		self._can_amend = False
 
 	@property
 	def commit_message(self) -> str:
-		return self._commit_state.value()
+		return self._commit_edit.value()
 
 	def set_commit_message(self, message: str) -> None:
-		self._commit_state.set_value(message)
+		self._commit_edit.set_value(message)
 
 	def persist_commit_draft(self, repo: Repository) -> None:
 		save_commit_draft(Path(repo.path), self.commit_message)
@@ -103,6 +114,13 @@ class ChangesList:
 	def is_at_top(self) -> bool:
 		return (self._list_state.selected or 0) == 0
 
+	def select_commit_row(self) -> None:
+		self._list_state.select(0)
+
+	def begin_input(self) -> None:
+		self._input_focus = "message"
+		self.select_commit_row()
+
 	def refresh(self, repo: Repository) -> None:
 		staged, unstaged, untracked = categorize_status(repo)
 		self._files = {
@@ -111,6 +129,7 @@ class ChangesList:
 			SectionId.UNTRACKED: untracked,
 		}
 		self._staged_count = len(staged)
+		self._can_amend = not repo.head_is_unborn
 		self._rebuild_rows()
 
 	def _rebuild_rows(self) -> None:
@@ -148,15 +167,20 @@ class ChangesList:
 		theme = current_theme()
 		list_area = area
 		if commit_input:
+			editor_height = max(_EDITOR_MIN_HEIGHT, min(_EDITOR_MAX_HEIGHT, max(1, area.height // 3)))
 			chunks = (
 				Layout()
 				.direction(Direction.Vertical)
-				.constraints([Constraint.length(1), Constraint.fill(1)])
+				.constraints([
+					Constraint.length(editor_height),
+					Constraint.length(1),
+					Constraint.fill(1),
+				])
 				.split(area)
 			)
-			self._commit_state.focus()
-			frame.render_text_prompt(TextPrompt("Commit: "), chunks[0], self._commit_state)
-			list_area = chunks[1]
+			self._draw_editor(frame, chunks[0], focused=self._input_focus == "message")
+			self._draw_buttons(frame, chunks[1])
+			list_area = chunks[2]
 
 		items: list[ListItem] = []
 		selected = self._list_state.selected or 0
@@ -187,8 +211,6 @@ class ChangesList:
 
 		if not items:
 			items.append(ListItem(" ", theme.normal))
-		# Commit row is drawn as a TextPrompt above the list; keep ListState on
-		# self._rows, but do not highlight a shifted list index while typing.
 		list_state = self._list_state
 		if commit_input:
 			list_state = ListState()
@@ -199,6 +221,34 @@ class ChangesList:
 			list_state,
 		)
 
+	def _draw_editor(self, frame: Frame, area: Rect, *, focused: bool) -> None:
+		theme = current_theme()
+		style = theme.commit
+		cursor_style = theme.focus if focused else theme.commit
+		text = self._commit_edit.render(
+			max(1, area.width),
+			max(1, area.height),
+			focused=focused,
+			style=style,
+			cursor_style=cursor_style,
+		)
+		frame.render_widget(Paragraph(text), area)
+
+	def _draw_buttons(self, frame: Frame, area: Rect) -> None:
+		theme = current_theme()
+		commit_style = theme.focus if self._input_focus == "commit" else theme.header
+		amend_style = theme.focus if self._input_focus == "amend" else theme.header
+		if not self._can_amend and self._input_focus != "amend":
+			amend_style = theme.log_date.dim()
+		text = Text([
+			Line([
+				Span("[ Commit ]", commit_style),
+				Span("  ", theme.normal),
+				Span("[ Commit (amend) ]", amend_style),
+			]),
+		])
+		frame.render_widget(Paragraph(text), area)
+
 	def handle_nav_key(self, key: KeyLike, repo: Repository) -> NavKeyResult:
 		if self._error is not None:
 			self._error = None
@@ -216,11 +266,10 @@ class ChangesList:
 
 		row = self._rows[self._list_state.selected or 0]
 		if row.kind == "commit":
-			if is_enter(key) and not self.commit_message.strip():
+			if is_enter(key) or (vim_enabled() and key.code in {"i", "I"}):
+				self.begin_input()
 				return NavKeyResult(enter_input=True)
-			if self._try_begin_input(key):
-				return NavKeyResult(enter_input=True)
-			return self._handle_commit_nav_key(key, repo)
+			return NavKeyResult()
 
 		if (is_left(key) or is_right(key) or is_space(key)) and row.kind == "header" and row.section is not None:
 			self._collapsed[row.section] = not self._collapsed[row.section]
@@ -239,18 +288,33 @@ class ChangesList:
 	def handle_input_key(self, key: KeyLike, repo: Repository) -> InputKeyResult:
 		if self._error is not None:
 			self._error = None
-		if is_enter(key):
-			ok, error = commit_staged(repo, self.commit_message)
-			if ok:
-				self._commit_state.set_value("")
-				self.persist_commit_draft(repo)
-				self._error = None
-				self.refresh(repo)
-				return InputKeyResult(layout_changed=True, relinquish=True, committed=True)
-			self._error = error
+
+		if is_tab(key):
+			self._input_focus = _next_focus(self._input_focus, self._can_amend)
+			return InputKeyResult()
+		if is_backtab(key):
+			self._input_focus = _prev_focus(self._input_focus, self._can_amend)
+			return InputKeyResult()
+
+		if self._input_focus == "message":
+			if key.code == "Down" and self._commit_edit.at_last_visual_line():
+				self._input_focus = "commit"
+				return InputKeyResult()
+			self._commit_edit.handle_key(key)
+			return InputKeyResult()
+
+		# Button row: arrows only so vim letters cannot steal focus while editing.
+		if key.code == "Up":
+			self._input_focus = "message"
+			return InputKeyResult()
+		if key.code == "Left" and self._input_focus == "amend":
+			self._input_focus = "commit"
 			return InputKeyResult()
-		self._commit_state.focus()
-		self._commit_state.handle_key(key)
+		if key.code == "Right" and self._input_focus == "commit" and self._can_amend:
+			self._input_focus = "amend"
+			return InputKeyResult()
+		if is_enter(key):
+			return self._run_commit(repo, amend=self._input_focus == "amend")
 		return InputKeyResult()
 
 	def status_hint(self) -> str:
@@ -258,48 +322,52 @@ class ChangesList:
 			return self._error
 		if not self._rows:
 			return "Working tree clean"
+		move = move_hint()
 		row = self._rows[self._list_state.selected or 0]
 		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"
+			insert = "Enter/i edit" if vim_enabled() else "Enter edit"
+			return f"{insert} — {move} — / command"
 		if row.kind == "header":
 			if row.section == SectionId.STAGED:
 				if self._staged_count == 0:
-					return "Enter stage all — Space collapse — j/k move"
-				return "Enter unstage all — Space collapse — j/k move"
-			return "Enter stage all — Space collapse — j/k move"
+					return f"Enter stage all — Space collapse — {move}"
+				return f"Enter unstage all — Space collapse — {move}"
+			return f"Enter stage all — Space collapse — {move}"
 		if row.section == SectionId.STAGED:
-			return "Enter unstage — j/k move"
-		return "Enter stage — j/k move"
-
-	def _try_begin_input(self, key: KeyLike) -> bool:
-		if is_backspace(key):
-			message = self.commit_message
-			if not message:
-				return False
-			self._commit_state.set_value(message[:-1])
-			return True
-		char = printable_char(key)
-		if char is not None:
-			self._commit_state.set_value(self.commit_message + char)
-			return True
-		return False
-
-	def _handle_commit_nav_key(self, key: KeyLike, repo: Repository) -> NavKeyResult:
-		if is_enter(key):
-			ok, error = commit_staged(repo, self.commit_message)
-			if ok:
-				self._commit_state.set_value("")
-				self.persist_commit_draft(repo)
-				self._error = None
-				self.refresh(repo)
-				return NavKeyResult(layout_changed=True, committed=True)
-			self._error = error
-		return NavKeyResult()
+			return f"Enter unstage — {move}"
+		return f"Enter stage — {move}"
+
+	def input_status_hint(self) -> str:
+		if self._error:
+			return self._error
+		if self._input_focus == "message":
+			return "Esc back — Enter newline — Tab buttons — ↓ Commit"
+		if self._input_focus == "commit":
+			return "Enter commit — ←/→ amend — Esc back"
+		if self._can_amend:
+			return "Enter amend — ←/→ commit — Esc back"
+		return "Nothing to amend — ← commit — Esc back"
+
+	def _run_commit(self, repo: Repository, *, amend: bool) -> InputKeyResult:
+		ok, error = commit_staged(repo, self.commit_message, amend=amend)
+		if ok:
+			self._commit_edit.set_value("")
+			self.persist_commit_draft(repo)
+			self._error = None
+			self.refresh(repo)
+			return InputKeyResult(layout_changed=True, relinquish=True, committed=True)
+		self._error = error
+		return InputKeyResult()
 
 	def _commit_label(self) -> str:
-		return f"Commit: {self.commit_message}"
+		message = self.commit_message
+		if not message.strip():
+			return "Commit: (edit message)"
+		lines = message.splitlines()
+		first = lines[0]
+		if len(lines) > 1:
+			return f"Commit: {first}…"
+		return f"Commit: {first}"
 
 	def _toggle_stage(self, repo: Repository, section: SectionId, path: str) -> None:
 		if section == SectionId.STAGED:
@@ -316,3 +384,19 @@ class ChangesList:
 				stage_paths(repo, self._files[SectionId.UNSTAGED] + self._files[SectionId.UNTRACKED])
 		else:
 			stage_paths(repo, paths)
+
+
+def _next_focus(current: _InputFocus, can_amend: bool) -> _InputFocus:
+	if current == "message":
+		return "commit"
+	if current == "commit":
+		return "amend" if can_amend else "message"
+	return "message"
+
+
+def _prev_focus(current: _InputFocus, can_amend: bool) -> _InputFocus:
+	if current == "message":
+		return "amend" if can_amend else "commit"
+	if current == "amend":
+		return "commit"
+	return "message"
diff --git a/pygittools/src/pygittools/tui/changes_list_test.py b/pygittools/src/pygittools/tui/changes_list_test.py
index 925f986..8018064 100644
--- a/pygittools/src/pygittools/tui/changes_list_test.py
+++ b/pygittools/src/pygittools/tui/changes_list_test.py
@@ -8,7 +8,7 @@ from pygit2 import Repository
 from pyratatui import Rect
 
 from pygittools.tui.changes_list import ChangesList, SectionId
-from pygittools.tui.keys import key
+from pygittools.tui.keys import key, set_vim_enabled
 from pygittools.tui.types import PageContext
 from pygittools.tui.worktree import categorize_status
 
@@ -22,7 +22,7 @@ def test_refresh_builds_sections(committed_repo: Repository) -> None:
 def test_refresh_clean_repo(page_ctx: PageContext) -> None:
 	changes = ChangesList()
 	changes.refresh(page_ctx.repo)
-	assert changes.status_hint() == "Enter type message — j/k move"
+	assert "Enter edit" in changes.status_hint()
 
 
 def test_nav_up_down(page_ctx: PageContext) -> None:
@@ -82,47 +82,73 @@ def test_collapse_does_not_reread_status(committed_repo: Repository) -> None:
 	assert any(row.kind == "header" and row.label.startswith("[>") for row in changes._rows)
 
 
-def test_commit_input_flow(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+def test_commit_input_flow(committed_repo: Repository) -> None:
 	(Path(committed_repo.workdir) / "new.txt").write_text("x\n", encoding="utf-8")
 	committed_repo.index.add("new.txt")
 	committed_repo.index.write()
 	changes = ChangesList()
 	changes.refresh(committed_repo)
 	changes._list_state.select(0)
-	changes.handle_nav_key(key("f"), committed_repo)
+	assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
+	changes.handle_input_key(key("f"), committed_repo)
 	assert changes.commit_message.endswith("f")
-	changes.handle_input_key(key("Backspace"), committed_repo)
+	changes.handle_input_key(key("Enter"), committed_repo)
+	assert "\n" in changes.commit_message
+	changes.handle_input_key(key("Tab"), committed_repo)
 	result = changes.handle_input_key(key("Enter"), committed_repo)
-	# may fail without staged depending on index state; exercise path
-	assert result is not None
+	assert result.committed is True
+	assert result.relinquish is True
 
 
-def test_commit_enter_with_message(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+def test_commit_button_with_message(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
 	changes = ChangesList()
 	changes.refresh(committed_repo)
 	changes.set_commit_message("msg")
+	changes.begin_input()
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (True, ""),
+		lambda _repo, _msg, *, amend=False: (True, ""),
 	)
-	result = changes.handle_nav_key(key("Enter"), committed_repo)
+	changes.handle_input_key(key("Tab"), committed_repo)
+	result = changes.handle_input_key(key("Enter"), committed_repo)
 	assert result.layout_changed is True
 	assert result.committed is True
 
 
-def test_commit_enter_failure(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+def test_amend_button(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
+	changes = ChangesList()
+	changes.refresh(committed_repo)
+	changes.set_commit_message("fix")
+	changes.begin_input()
+	seen: dict[str, bool] = {}
+
+	def _commit(_repo: Repository, _msg: str, *, amend: bool = False) -> tuple[bool, str]:
+		seen["amend"] = amend
+		return True, ""
+
+	monkeypatch.setattr("pygittools.tui.changes_list.commit_staged", _commit)
+	changes.handle_input_key(key("Tab"), committed_repo)
+	changes.handle_input_key(key("Right"), committed_repo)
+	result = changes.handle_input_key(key("Enter"), committed_repo)
+	assert result.committed is True
+	assert seen["amend"] is True
+
+
+def test_commit_button_failure(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
 	changes = ChangesList()
 	changes.refresh(committed_repo)
 	changes.set_commit_message("msg")
+	changes.begin_input()
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (False, "nope"),
+		lambda _repo, _msg, *, amend=False: (False, "nope"),
 	)
-	changes.handle_nav_key(key("Enter"), committed_repo)
-	assert "nope" in changes.status_hint()
+	changes.handle_input_key(key("Tab"), committed_repo)
+	changes.handle_input_key(key("Enter"), committed_repo)
+	assert "nope" in changes.input_status_hint()
 
 
-def test_enter_empty_commit_starts_input(committed_repo: Repository) -> None:
+def test_enter_commit_row_starts_input(committed_repo: Repository) -> None:
 	changes = ChangesList()
 	changes.refresh(committed_repo)
 	changes._list_state.select(0)
@@ -130,6 +156,19 @@ def test_enter_empty_commit_starts_input(committed_repo: Repository) -> None:
 	assert result.enter_input is True
 
 
+def test_vim_i_starts_input(committed_repo: Repository) -> None:
+	set_vim_enabled(True)
+	try:
+		changes = ChangesList()
+		changes.refresh(committed_repo)
+		changes._list_state.select(0)
+		assert "Enter/i" in changes.status_hint()
+		result = changes.handle_nav_key(key("i"), committed_repo)
+		assert result.enter_input is True
+	finally:
+		set_vim_enabled(False)
+
+
 def test_stage_file_hint(committed_repo: Repository) -> None:
 	path = Path(committed_repo.workdir) / "tracked.txt"
 	path.write_text("v2\n", encoding="utf-8")
@@ -163,7 +202,7 @@ def test_draw_commit_input_does_not_highlight_shifted_row(committed_repo: Reposi
 	assert (changes._list_state.selected or 0) == 0
 
 
-def test_persist_commit_draft(page_ctx: PageContext, tmp_path: Path) -> None:
+def test_persist_commit_draft(page_ctx: PageContext) -> None:
 	changes = ChangesList()
 	changes.set_commit_message("draft")
 	changes.persist_commit_draft(page_ctx.repo)
diff --git a/pygittools/src/pygittools/tui/coverage_gaps_test.py b/pygittools/src/pygittools/tui/coverage_gaps_test.py
index 45c45f1..bc36fde 100644
--- a/pygittools/src/pygittools/tui/coverage_gaps_test.py
+++ b/pygittools/src/pygittools/tui/coverage_gaps_test.py
@@ -66,14 +66,15 @@ def test_changes_list_nav_with_no_rows_after_collapse(committed_repo: Repository
 	assert changes.handle_nav_key(key("Up"), committed_repo).layout_changed is False
 
 
-def test_changes_list_commit_hints_and_backspace(committed_repo: Repository) -> None:
+def test_changes_list_commit_hints_and_edit(committed_repo: Repository) -> None:
 	changes = ChangesList()
 	changes.set_commit_message("msg")
 	changes.refresh(committed_repo)
 	changes._list_state.select(0)
-	assert "Enter commit" in changes.status_hint()
-	changes.handle_nav_key(key("Backspace"), committed_repo)
-	changes.handle_nav_key(key("Backspace"), committed_repo)
+	assert "Enter edit" in changes.status_hint()
+	assert changes.handle_nav_key(key("Enter"), committed_repo).enter_input is True
+	changes.handle_input_key(key("Backspace"), committed_repo)
+	assert changes.commit_message == "ms"
 
 
 def test_changes_list_staged_header_unstage_all(committed_repo: Repository) -> None:
@@ -203,11 +204,16 @@ def test_home_page_relinquish_input(committed_repo: Repository, monkeypatch: pyt
 	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
 	page = home_page()
 	page.on_enter(ctx)
+	page.handle_key(key("/"))
+	for char in "commit":
+		page.handle_key(key(char))
+	page.handle_key(key("Enter"))
 	page.handle_key(key("a"))
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (True, ""),
+		lambda _repo, _msg, *, amend=False: (True, ""),
 	)
+	page.handle_key(key("Tab"))
 	page.handle_key(key("Enter"))
 	assert page.status_text()
 
diff --git a/pygittools/src/pygittools/tui/input_context.py b/pygittools/src/pygittools/tui/input_context.py
index c1a3015..6cf1382 100644
--- a/pygittools/src/pygittools/tui/input_context.py
+++ b/pygittools/src/pygittools/tui/input_context.py
@@ -1,4 +1,4 @@
-"""Keyboard input modes for TUI pages (navigation vs text entry)."""
+"""Keyboard input modes for TUI pages (navigation, slash commands, text entry)."""
 
 from __future__ import annotations
 
@@ -6,7 +6,21 @@ 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.keys import (
+	KeyLike,
+	is_backspace,
+	is_down,
+	is_enter,
+	is_esc,
+	is_left,
+	is_right,
+	is_slash,
+	is_up,
+	move_hint,
+	printable_char,
+	set_vim_enabled,
+	tab_hint,
+)
 from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.actions import actions_page
 from pygittools.tui.pages.branches import branch_picker_page
@@ -21,13 +35,17 @@ if TYPE_CHECKING:
 
 _HeaderFocus = Literal["changes", "repo", "branch", "actions"]
 _HomeTab = Literal["status", "log", "tasks", "worktrees"]
+_ModeName = Literal["nav", "input", "command"]
+
+_COMMAND_HELP = "help actions quit commit vim novim status log tasks worktrees projects branch"
 
 
 @dataclass(frozen=True, slots=True)
 class ContextDispatch:
 	page_result: PageResult | None = None
-	next_mode: Literal["nav", "input"] | None = None
+	next_mode: _ModeName | None = None
 	refresh_log: bool = False
+	command_error: str | None = None
 
 
 class PageInputHost(Protocol):
@@ -62,18 +80,19 @@ class PageInputHost(Protocol):
 
 class NavContext:
 	def status_hint(self, host: PageInputHost) -> str:
+		suffix = f"{move_hint()} — {tab_hint()} — / command"
 		if host.header_focus == "repo":
-			return "Enter browse projects — j/k move — H/L switch tab"
+			return f"Enter browse projects — {suffix}"
 		if host.header_focus == "branch":
 			if host.active_tab == "tasks":
-				return "Enter sync tasks — j/k move — H/L switch tab"
+				return f"Enter sync tasks — {suffix}"
 			if host.active_tab == "worktrees":
-				return "Enter add worktree — j/k move — H/L switch tab"
-			return "Enter switch branch — j/k move — H/L switch tab"
+				return f"Enter add worktree — {suffix}"
+			return f"Enter switch branch — {suffix}"
 		if host.header_focus == "actions":
 			if host.has_actions:
-				return "Enter open actions — j/k move — H/L switch tab"
-			return "No action groups — j/k move — H/L switch tab"
+				return f"Enter open actions — {suffix}"
+			return f"No action groups — {suffix}"
 		if host.active_tab == "log":
 			return host.log.status_hint()
 		if host.active_tab == "tasks":
@@ -83,14 +102,12 @@ class NavContext:
 		return host.changes.status_hint()
 
 	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+		if is_slash(key):
+			return ContextDispatch(next_mode="command")
+
 		if _try_switch_tab(host, key):
 			return ContextDispatch()
 
-		if key.code == "?":
-			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=help_page()))
-		if key.code == "!":
-			return ContextDispatch(page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()))
-
 		if host.header_focus == "repo":
 			if is_down(key):
 				host.set_header_focus("branch")
@@ -147,10 +164,100 @@ class NavContext:
 		return ContextDispatch(refresh_log=nav_result.committed)
 
 
-class InputContext:
+class CommandContext:
+	def __init__(self) -> None:
+		self._buffer = ""
+		self._error: str | None = None
+
 	def status_hint(self, host: PageInputHost) -> str:
 		del host
-		return "Esc relinquish input — Enter commit"
+		if self._error is not None:
+			return self._error
+		if not self._buffer:
+			return f"/ — {_COMMAND_HELP} — Esc cancel"
+		return f"/{self._buffer}"
+
+	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
+		if is_esc(key):
+			return ContextDispatch(next_mode="nav")
+		if is_backspace(key):
+			self._error = None
+			self._buffer = self._buffer[:-1]
+			return ContextDispatch()
+		if is_enter(key):
+			return self._run(host)
+		char = printable_char(key)
+		if char is not None and not char.isspace():
+			self._error = None
+			self._buffer += char
+			return ContextDispatch()
+		return ContextDispatch()
+
+	def _run(self, host: PageInputHost) -> ContextDispatch:
+		raw = self._buffer.strip().casefold()
+		name = raw.lstrip("/")
+		self._buffer = ""
+		if not name:
+			return ContextDispatch(next_mode="nav")
+
+		if name in {"help", "?"}:
+			return ContextDispatch(
+				next_mode="nav",
+				page_result=PageResult(action=PageAction.PUSH, next_page=help_page()),
+			)
+		if name in {"actions", "!"}:
+			return ContextDispatch(
+				next_mode="nav",
+				page_result=PageResult(action=PageAction.PUSH, next_page=actions_page()),
+			)
+		if name in {"quit", "q"}:
+			return ContextDispatch(next_mode="nav", page_result=PageResult(action=PageAction.QUIT))
+		if name == "commit":
+			host.set_active_tab("status")
+			host.set_header_focus("changes")
+			host.changes.begin_input()
+			return ContextDispatch(next_mode="input")
+		if name == "vim":
+			set_vim_enabled(True)
+			return ContextDispatch(next_mode="nav")
+		if name in {"novim", "arrows"}:
+			set_vim_enabled(False)
+			return ContextDispatch(next_mode="nav")
+		tab = _tab_command(name)
+		if tab is not None:
+			host.set_active_tab(tab)
+			host.set_header_focus("changes")
+			return ContextDispatch(next_mode="nav")
+		if name == "projects":
+			return ContextDispatch(
+				next_mode="nav",
+				page_result=PageResult(action=PageAction.PUSH, next_page=projects_page()),
+			)
+		if name == "branch":
+			return ContextDispatch(
+				next_mode="nav",
+				page_result=PageResult(action=PageAction.PUSH, next_page=branch_picker_page()),
+			)
+
+		self._error = f"Unknown command: /{name} — {_COMMAND_HELP}"
+		return ContextDispatch()
+
+
+_TAB_COMMANDS: dict[str, _HomeTab] = {
+	"status": "status",
+	"log": "log",
+	"tasks": "tasks",
+	"worktrees": "worktrees",
+}
+
+
+def _tab_command(name: str) -> _HomeTab | None:
+	return _TAB_COMMANDS.get(name)
+
+
+class InputContext:
+	def status_hint(self, host: PageInputHost) -> str:
+		return host.changes.input_status_hint()
 
 	def dispatch_key(self, host: PageInputHost, key: KeyLike) -> ContextDispatch:
 		if is_esc(key):
@@ -163,7 +270,7 @@ class InputContext:
 		return ContextDispatch()
 
 
-InputMode = NavContext | InputContext
+InputMode = NavContext | InputContext | CommandContext
 
 
 def nav_context() -> NavContext:
@@ -174,6 +281,10 @@ def input_context() -> InputContext:
 	return InputContext()
 
 
+def command_context() -> CommandContext:
+	return CommandContext()
+
+
 def _show_actions_row(host: PageInputHost) -> bool:
 	return host.active_tab == "status"
 
diff --git a/pygittools/src/pygittools/tui/input_context_test.py b/pygittools/src/pygittools/tui/input_context_test.py
index b7f206a..54b4a7e 100644
--- a/pygittools/src/pygittools/tui/input_context_test.py
+++ b/pygittools/src/pygittools/tui/input_context_test.py
@@ -8,8 +8,8 @@ import pytest
 from pyratatui import Rect
 
 from pygittools.tui.changes_list import ChangesList
-from pygittools.tui.input_context import InputContext, input_context, nav_context
-from pygittools.tui.keys import key
+from pygittools.tui.input_context import InputContext, command_context, input_context, nav_context
+from pygittools.tui.keys import key, set_vim_enabled, vim_enabled
 from pygittools.tui.log_list import CommitLogList
 from pygittools.tui.pages.actions import ActionsPage
 from pygittools.tui.pages.help import HelpPage
@@ -41,6 +41,7 @@ class FakeHost:
 
 @pytest.fixture
 def host(page_ctx: PageContext) -> FakeHost:
+	set_vim_enabled(False)
 	changes = ChangesList()
 	changes.refresh(page_ctx.repo)
 	log = CommitLogList()
@@ -60,23 +61,78 @@ def test_nav_context_status_hints(host: FakeHost) -> None:
 	assert "switch branch" in nav.status_hint(host)
 	host.active_tab = "log"
 	host.header_focus = "changes"
-	assert "j/k move" in nav.status_hint(host)
+	assert "arrows move" in nav.status_hint(host)
 
 
-def test_nav_context_opens_help(host: FakeHost) -> None:
-	dispatch = nav_context().dispatch_key(host, key("?"))
+def test_nav_context_opens_command_mode(host: FakeHost) -> None:
+	dispatch = nav_context().dispatch_key(host, key("/"))
+	assert dispatch.next_mode == "command"
+
+
+def _run_command(host: FakeHost, command: str):
+	cmd = command_context()
+	for char in command:
+		cmd.dispatch_key(host, key(char))
+	return cmd.dispatch_key(host, key("Enter"))
+
+
+def test_command_opens_help(host: FakeHost) -> None:
+	dispatch = _run_command(host, "help")
 	assert dispatch.page_result is not None
 	assert dispatch.page_result.action == PageAction.PUSH
 	assert isinstance(dispatch.page_result.next_page, HelpPage)
+	assert dispatch.next_mode == "nav"
 
 
-def test_nav_context_opens_actions(host: FakeHost) -> None:
-	dispatch = nav_context().dispatch_key(host, key("!"))
+def test_command_opens_actions(host: FakeHost) -> None:
+	dispatch = _run_command(host, "actions")
 	assert dispatch.page_result is not None
 	assert dispatch.page_result.action == PageAction.PUSH
 	assert isinstance(dispatch.page_result.next_page, ActionsPage)
 
 
+def test_command_quit(host: FakeHost) -> None:
+	dispatch = _run_command(host, "quit")
+	assert dispatch.page_result is not None
+	assert dispatch.page_result.action == PageAction.QUIT
+
+
+def test_command_commit_enters_input(host: FakeHost) -> None:
+	dispatch = _run_command(host, "commit")
+	assert dispatch.next_mode == "input"
+	assert host.active_tab == "status"
+	assert host.header_focus == "changes"
+
+
+def test_command_vim_toggle(host: FakeHost) -> None:
+	assert not vim_enabled()
+	assert _run_command(host, "vim").next_mode == "nav"
+	assert vim_enabled()
+	assert _run_command(host, "novim").next_mode == "nav"
+	assert not vim_enabled()
+
+
+def test_command_switch_tab(host: FakeHost) -> None:
+	assert _run_command(host, "log").next_mode == "nav"
+	assert host.active_tab == "log"
+
+
+def test_command_unknown(host: FakeHost) -> None:
+	cmd = command_context()
+	for char in "nope":
+		cmd.dispatch_key(host, key(char))
+	dispatch = cmd.dispatch_key(host, key("Enter"))
+	assert dispatch.next_mode is None
+	assert "Unknown command" in cmd.status_hint(host)
+
+
+def test_command_esc_cancels(host: FakeHost) -> None:
+	cmd = command_context()
+	cmd.dispatch_key(host, key("h"))
+	dispatch = cmd.dispatch_key(host, key("Esc"))
+	assert dispatch.next_mode == "nav"
+
+
 def test_nav_context_repo_header(host: FakeHost) -> None:
 	host.header_focus = "repo"
 	nav = nav_context()
@@ -150,12 +206,31 @@ def test_nav_context_log_tab(host: FakeHost) -> None:
 	assert (host.log._list_state.selected or 0) >= 0
 
 
-def test_nav_context_enters_input(host: FakeHost) -> None:
+def test_nav_context_enters_input_on_enter(host: FakeHost) -> None:
 	host.changes._list_state.select(0)
-	dispatch = nav_context().dispatch_key(host, key("a"))
+	dispatch = nav_context().dispatch_key(host, key("Enter"))
 	assert dispatch.next_mode == "input"
 
 
+def test_nav_context_vim_i_enters_input(host: FakeHost) -> None:
+	set_vim_enabled(True)
+	try:
+		host.changes._list_state.select(0)
+		dispatch = nav_context().dispatch_key(host, key("i"))
+		assert dispatch.next_mode == "input"
+	finally:
+		set_vim_enabled(False)
+
+
+def test_nav_ignores_letter_keys_without_vim(host: FakeHost) -> None:
+	host.changes._list_state.select(0)
+	selected = host.changes._list_state.selected
+	dispatch = nav_context().dispatch_key(host, key("j"))
+	assert dispatch.next_mode is None
+	assert host.changes._list_state.selected == selected
+	assert host.changes.commit_message == ""
+
+
 def test_input_context_esc_returns_nav(host: FakeHost) -> None:
 	dispatch = input_context().dispatch_key(host, key("Esc"))
 	assert dispatch.next_mode == "nav"
@@ -163,18 +238,20 @@ def test_input_context_esc_returns_nav(host: FakeHost) -> None:
 
 def test_input_context_commit_relinquishes(host: FakeHost, monkeypatch: pytest.MonkeyPatch) -> None:
 	host.changes.set_commit_message("msg")
+	host.changes.begin_input()
+	host.changes.refresh(host.page_context.repo)  # type: ignore[union-attr]
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (True, ""),
+		lambda _repo, _msg, *, amend=False: (True, ""),
 	)
+	input_context().dispatch_key(host, key("Tab"))
 	dispatch = input_context().dispatch_key(host, key("Enter"))
 	assert dispatch.next_mode == "nav"
 
 
-def test_input_context_status_hint() -> None:
-	assert "Esc" in InputContext().status_hint(
-		FakeHost(None, ChangesList(), CommitLogList(), TasksBoardList(), WorktreesList()),
-	)
+def test_input_context_status_hint(host: FakeHost) -> None:
+	host.changes.begin_input()
+	assert "Esc" in InputContext().status_hint(host)
 
 
 def test_nav_context_without_page_context() -> None:
diff --git a/pygittools/src/pygittools/tui/keys.py b/pygittools/src/pygittools/tui/keys.py
index b1b7e49..ea061b6 100644
--- a/pygittools/src/pygittools/tui/keys.py
+++ b/pygittools/src/pygittools/tui/keys.py
@@ -5,6 +5,8 @@ from __future__ import annotations
 from dataclasses import dataclass
 from typing import Protocol, runtime_checkable
 
+_vim_enabled: bool = False
+
 
 @runtime_checkable
 class KeyLike(Protocol):
@@ -35,24 +37,41 @@ def key(code: str, *, ctrl: bool = False, alt: bool = False, shift: bool = False
 	return Key(code=code, ctrl=ctrl, alt=alt, shift=shift)
 
 
+def set_vim_enabled(enabled: bool) -> None:
+	global _vim_enabled
+	_vim_enabled = enabled
+
+
+def vim_enabled() -> bool:
+	return _vim_enabled
+
+
 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"}
+	if ev.code == "Up":
+		return True
+	return _vim_enabled and ev.code in {"k", "K"}
 
 
 def is_down(ev: KeyLike) -> bool:
-	return ev.code in {"Down", "j", "J"}
+	if ev.code == "Down":
+		return True
+	return _vim_enabled and ev.code in {"j", "J"}
 
 
 def is_left(ev: KeyLike) -> bool:
-	return ev.code in {"Left", "h", "H"}
+	if ev.code == "Left":
+		return True
+	return _vim_enabled and ev.code in {"h", "H"}
 
 
 def is_right(ev: KeyLike) -> bool:
-	return ev.code in {"Right", "l", "L"}
+	if ev.code == "Right":
+		return True
+	return _vim_enabled and ev.code in {"l", "L"}
 
 
 def is_enter(ev: KeyLike) -> bool:
@@ -79,9 +98,25 @@ def is_space(ev: KeyLike) -> bool:
 	return ev.code == " "
 
 
+def is_slash(ev: KeyLike) -> bool:
+	return ev.code == "/" and not ev.ctrl and not ev.alt
+
+
 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
+
+
+def move_hint() -> str:
+	return "j/k move" if _vim_enabled else "arrows move"
+
+
+def tab_hint() -> str:
+	return "H/L switch tab" if _vim_enabled else "←/→ switch tab"
+
+
+def board_hint() -> str:
+	return "H/L switch board" if _vim_enabled else "←/→ switch board"
diff --git a/pygittools/src/pygittools/tui/log_list.py b/pygittools/src/pygittools/tui/log_list.py
index 762aeda..c8e6f69 100644
--- a/pygittools/src/pygittools/tui/log_list.py
+++ b/pygittools/src/pygittools/tui/log_list.py
@@ -10,7 +10,7 @@ from pygit2.enums import SortMode
 from pyratatui import Frame, List, ListItem, ListState, Rect
 
 from pygittools.tui.git_colors import current_theme, log_entry_styles
-from pygittools.tui.keys import KeyLike, is_down, is_up
+from pygittools.tui.keys import KeyLike, is_down, is_up, move_hint
 
 _MAX_COMMITS = 500
 
@@ -81,10 +81,11 @@ class CommitLogList:
 		return LogNavKeyResult()
 
 	def status_hint(self) -> str:
+		move = move_hint()
 		if not self._entries:
-			return "No commits — j/k move"
+			return f"No commits — {move}"
 		entry = self._entries[self._list_state.selected or 0]
-		return f"{entry.short_id} {entry.date} — j/k move"
+		return f"{entry.short_id} {entry.date} — {move}"
 
 
 def _load_entries(repo: Repository) -> list[_LogEntry]:
diff --git a/pygittools/src/pygittools/tui/log_list_test.py b/pygittools/src/pygittools/tui/log_list_test.py
index 53f6158..15b92a0 100644
--- a/pygittools/src/pygittools/tui/log_list_test.py
+++ b/pygittools/src/pygittools/tui/log_list_test.py
@@ -49,7 +49,7 @@ def test_commit_log_list_loads_head_commit(tmp_path: Path) -> None:
 def test_commit_log_list_empty_repo(repo: pygit2.Repository) -> None:
 	log = CommitLogList()
 	log.refresh(repo)
-	assert log.status_hint() == "No commits — j/k move"
+	assert log.status_hint() == "No commits — arrows move"
 	log.draw(MagicMock(), Rect(0, 0, 80, 5), highlight=True)
 
 
diff --git a/pygittools/src/pygittools/tui/multiline_edit.py b/pygittools/src/pygittools/tui/multiline_edit.py
new file mode 100644
index 0000000..20aede8
--- /dev/null
+++ b/pygittools/src/pygittools/tui/multiline_edit.py
@@ -0,0 +1,184 @@
+"""Soft-wrapped multiline text buffer for the commit message editor."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pyratatui import Line, Span, Style, Text
+
+from pygittools.tui.keys import KeyLike, is_backspace, is_enter, printable_char
+
+
+@dataclass(frozen=True, slots=True)
+class _VisualLine:
+	text: str
+	start: int
+	hard_break: bool
+
+
+class MultilineEdit:
+	def __init__(self, initial: str = "") -> None:
+		self._text = initial
+		self._cursor = len(initial)
+		self._width = 40
+
+	def value(self) -> str:
+		return self._text
+
+	def set_value(self, value: str) -> None:
+		self._text = value
+		self._cursor = len(value)
+
+	@property
+	def cursor(self) -> int:
+		return self._cursor
+
+	def handle_key(self, key: KeyLike) -> bool:
+		# Arrow keys only — vim letters must insert as text while editing.
+		if key.code == "Left":
+			self._cursor = max(0, self._cursor - 1)
+			return True
+		if key.code == "Right":
+			self._cursor = min(len(self._text), self._cursor + 1)
+			return True
+		if key.code == "Up":
+			self._move_vertical(-1)
+			return True
+		if key.code == "Down":
+			self._move_vertical(1)
+			return True
+		if key.code == "Home":
+			line = self._visual_index()
+			self._cursor = self._visual_lines()[line].start
+			return True
+		if key.code == "End":
+			lines = self._visual_lines()
+			line = self._visual_index()
+			visual = lines[line]
+			self._cursor = visual.start + len(visual.text)
+			return True
+		if is_backspace(key):
+			if self._cursor > 0:
+				self._text = self._text[: self._cursor - 1] + self._text[self._cursor :]
+				self._cursor -= 1
+			return True
+		if key.code == "Delete":
+			if self._cursor < len(self._text):
+				self._text = self._text[: self._cursor] + self._text[self._cursor + 1 :]
+			return True
+		if is_enter(key):
+			self._insert("\n")
+			return True
+		char = printable_char(key)
+		if char is not None:
+			self._insert(char)
+			return True
+		return False
+
+	def at_last_visual_line(self) -> bool:
+		lines = self._visual_lines()
+		return self._visual_index() >= len(lines) - 1
+
+	def at_first_visual_line(self) -> bool:
+		return self._visual_index() <= 0
+
+	def render(
+		self,
+		width: int,
+		height: int,
+		*,
+		focused: bool,
+		style: Style,
+		cursor_style: Style,
+	) -> Text:
+		self._width = max(1, width)
+		lines = self._visual_lines()
+		cursor_line = self._visual_index()
+		scroll = 0
+		if height > 0 and cursor_line >= height:
+			scroll = cursor_line - height + 1
+		text = Text()
+		for row in range(height):
+			index = scroll + row
+			if index >= len(lines):
+				text.push_line(Line([Span(" ", style)]))
+				continue
+			visual = lines[index]
+			if focused and index == cursor_line:
+				text.push_line(self._line_with_cursor(visual, style, cursor_style))
+			else:
+				content = visual.text if visual.text else " "
+				text.push_line(Line([Span(content, style)]))
+		return text
+
+	def _insert(self, chunk: str) -> None:
+		self._text = self._text[: self._cursor] + chunk + self._text[self._cursor :]
+		self._cursor += len(chunk)
+
+	def _visual_lines(self) -> list[_VisualLine]:
+		width = max(1, self._width)
+		lines: list[_VisualLine] = []
+		offset = 0
+		parts = self._text.split("\n")
+		for part_index, part in enumerate(parts):
+			hard = part_index < len(parts) - 1
+			if part == "":
+				lines.append(_VisualLine(text="", start=offset, hard_break=hard))
+				offset += 1 if hard else 0
+				continue
+			start = offset
+			while part:
+				chunk = part[:width]
+				part = part[width:]
+				is_last = not part
+				lines.append(
+					_VisualLine(
+						text=chunk,
+						start=start,
+						hard_break=hard and is_last,
+					),
+				)
+				start += len(chunk)
+			offset = start + (1 if hard else 0)
+		if not lines:
+			lines.append(_VisualLine(text="", start=0, hard_break=False))
+		return lines
+
+	def _visual_index(self) -> int:
+		lines = self._visual_lines()
+		for index, visual in enumerate(lines):
+			end = visual.start + len(visual.text)
+			if self._cursor < end:
+				return index
+			if self._cursor == end:
+				return index
+		return len(lines) - 1
+
+	def _move_vertical(self, delta: int) -> None:
+		lines = self._visual_lines()
+		index = self._visual_index()
+		target = max(0, min(len(lines) - 1, index + delta))
+		if target == index:
+			return
+		col = self._cursor - lines[index].start
+		visual = lines[target]
+		self._cursor = visual.start + min(col, len(visual.text))
+
+	def _line_with_cursor(self, visual: _VisualLine, style: Style, cursor_style: Style) -> Line:
+		col = max(0, self._cursor - visual.start)
+		if col < len(visual.text):
+			before = visual.text[:col]
+			cursor_ch = visual.text[col]
+			after = visual.text[col + 1 :]
+			spans = []
+			if before:
+				spans.append(Span(before, style))
+			spans.append(Span(cursor_ch, cursor_style))
+			if after:
+				spans.append(Span(after, style))
+			return Line(spans)
+		spans = []
+		if visual.text:
+			spans.append(Span(visual.text, style))
+		spans.append(Span(" ", cursor_style))
+		return Line(spans)
diff --git a/pygittools/src/pygittools/tui/multiline_edit_test.py b/pygittools/src/pygittools/tui/multiline_edit_test.py
new file mode 100644
index 0000000..8d53386
--- /dev/null
+++ b/pygittools/src/pygittools/tui/multiline_edit_test.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pyratatui import Style
+
+from pygittools.tui.keys import key
+from pygittools.tui.multiline_edit import MultilineEdit
+
+
+def test_insert_and_newline() -> None:
+	edit = MultilineEdit()
+	edit.handle_key(key("a"))
+	edit.handle_key(key("b"))
+	edit.handle_key(key("Enter"))
+	edit.handle_key(key("c"))
+	assert edit.value() == "ab\nc"
+	assert edit.cursor == 4
+
+
+def test_arrows_move_cursor() -> None:
+	edit = MultilineEdit("hi")
+	edit.handle_key(key("Left"))
+	assert edit.cursor == 1
+	edit.handle_key(key("Right"))
+	assert edit.cursor == 2
+
+
+def test_vim_letters_insert_not_move() -> None:
+	edit = MultilineEdit()
+	edit.handle_key(key("j"))
+	edit.handle_key(key("k"))
+	assert edit.value() == "jk"
+
+
+def test_soft_wrap_render() -> None:
+	edit = MultilineEdit("abcdefgh")
+	edit._width = 3
+	text = edit.render(3, 4, focused=True, style=Style(), cursor_style=Style().reversed())
+	assert text.height >= 3
+
+
+def test_backspace_and_delete() -> None:
+	edit = MultilineEdit("ab")
+	edit.handle_key(key("Left"))
+	edit.handle_key(key("Delete"))
+	assert edit.value() == "a"
+	edit.handle_key(key("Backspace"))
+	assert edit.value() == ""
diff --git a/pygittools/src/pygittools/tui/pages/actions.py b/pygittools/src/pygittools/tui/pages/actions.py
index 1d0b67a..2d271dd 100644
--- a/pygittools/src/pygittools/tui/pages/actions.py
+++ b/pygittools/src/pygittools/tui/pages/actions.py
@@ -33,6 +33,8 @@ from pygittools.tui.keys import (
 	is_right,
 	is_tab,
 	is_up,
+	move_hint,
+	vim_enabled,
 )
 from pygittools.tui.tabs import render_tab_row
 from pygittools.tui.types import PageAction, PageContext, PageResult
@@ -122,9 +124,11 @@ class ActionsPage:
 			return "No pyproject.toml or actions.toml found — Esc back"
 		if not self._sets:
 			return "No actions configured — Esc back"
-		hint = "Enter run — A run all — j/k move — Esc back"
+		move = move_hint()
+		hint = f"Enter run — A run all — {move} — Esc back"
 		if len(self._sets) > 1:
-			return f"H/L or Tab/Shift-Tab sets — {hint}"
+			set_keys = "H/L or Tab/Shift-Tab" if vim_enabled() else "←/→ or Tab/Shift-Tab"
+			return f"{set_keys} sets — {hint}"
 		return hint
 
 	def draw(self, frame: Frame, area: Rect) -> None:
diff --git a/pygittools/src/pygittools/tui/pages/branches.py b/pygittools/src/pygittools/tui/pages/branches.py
index f208ac5..fc1e7a1 100644
--- a/pygittools/src/pygittools/tui/pages/branches.py
+++ b/pygittools/src/pygittools/tui/pages/branches.py
@@ -23,7 +23,7 @@ from pygittools.tui.branches import (
 	list_local_branches,
 )
 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.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up, move_hint
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 
@@ -56,7 +56,7 @@ class BranchPickerPage:
 			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"
+		return f"Enter checkout — type to filter — {move_hint()} — Esc back"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0:
diff --git a/pygittools/src/pygittools/tui/pages/help.py b/pygittools/src/pygittools/tui/pages/help.py
index 02456ba..98c5389 100644
--- a/pygittools/src/pygittools/tui/pages/help.py
+++ b/pygittools/src/pygittools/tui/pages/help.py
@@ -33,7 +33,7 @@ class HelpPage:
 			return "Help"
 		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"
+		return f"Help {visible}/{total} — arrows to scroll, Q or Esc to go back"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0 or area.width <= 0:
diff --git a/pygittools/src/pygittools/tui/pages/home.py b/pygittools/src/pygittools/tui/pages/home.py
index d4d34e1..c57703b 100644
--- a/pygittools/src/pygittools/tui/pages/home.py
+++ b/pygittools/src/pygittools/tui/pages/home.py
@@ -13,7 +13,14 @@ 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.git_colors import current_theme, focus_style
-from pygittools.tui.input_context import InputContext, InputMode, input_context, nav_context
+from pygittools.tui.input_context import (
+	CommandContext,
+	InputContext,
+	InputMode,
+	command_context,
+	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
@@ -82,7 +89,7 @@ class HomePage:
 	def set_active_tab(self, tab: _HomeTab) -> None:
 		self._active_tab = tab
 		if tab != "status":
-			if isinstance(self._input_mode, InputContext):
+			if isinstance(self._input_mode, (InputContext, CommandContext)):
 				self._input_mode = nav_context()
 			if self._header_focus == "actions":
 				self._header_focus = "changes"
@@ -183,12 +190,14 @@ class HomePage:
 			self._worktrees.draw(frame, content, highlight=content_highlight)
 
 	def handle_key(self, key: KeyLike) -> PageResult:
-		if is_quit(key) and not isinstance(self._input_mode, InputContext):
+		if is_quit(key) and not isinstance(self._input_mode, (InputContext, CommandContext)):
 			return PageResult(action=PageAction.QUIT)
 
 		dispatch = self._input_mode.dispatch_key(self, key)
 		if dispatch.next_mode == "input":
 			self._input_mode = input_context()
+		elif dispatch.next_mode == "command":
+			self._input_mode = command_context()
 		elif dispatch.next_mode == "nav":
 			self._input_mode = nav_context()
 		if dispatch.refresh_log and self._ctx is not None:
diff --git a/pygittools/src/pygittools/tui/pages/no_repo.py b/pygittools/src/pygittools/tui/pages/no_repo.py
index 4038161..0db966a 100644
--- a/pygittools/src/pygittools/tui/pages/no_repo.py
+++ b/pygittools/src/pygittools/tui/pages/no_repo.py
@@ -6,7 +6,7 @@ from pathlib import Path
 
 from pyratatui import Frame, Paragraph, Rect, Style, Text
 
-from pygittools.tui.keys import KeyLike, is_quit
+from pygittools.tui.keys import KeyLike, is_quit, is_slash
 from pygittools.tui.pages.help import help_page
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
@@ -16,12 +16,20 @@ class NoRepoPage:
 
 	def __init__(self, start: Path) -> None:
 		self._start = start
+		self._command: str | None = None
+		self._command_error: str | None = None
 
 	def on_enter(self, ctx: PageContext) -> None:
 		del ctx
 
 	def status_text(self) -> str:
-		return "no repository"
+		if self._command_error is not None:
+			return self._command_error
+		if self._command is not None:
+			if not self._command:
+				return "/ — help quit — Esc cancel"
+			return f"/{self._command}"
+		return "no repository — / command — q quit"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		lines = "\n".join([
@@ -37,8 +45,38 @@ class NoRepoPage:
 		)
 
 	def handle_key(self, key: KeyLike) -> PageResult:
+		if self._command is not None:
+			return self._handle_command_key(key)
 		if is_quit(key):
 			return PageResult(action=PageAction.QUIT)
-		if key.code.casefold() == "h":
-			return PageResult(action=PageAction.PUSH, next_page=help_page())
+		if is_slash(key):
+			self._command = ""
+			self._command_error = None
+			return PageResult()
+		return PageResult()
+
+	def _handle_command_key(self, key: KeyLike) -> PageResult:
+		assert self._command is not None
+		if key.code == "Esc":
+			self._command = None
+			self._command_error = None
+			return PageResult()
+		if key.code == "Backspace":
+			self._command_error = None
+			self._command = self._command[:-1]
+			return PageResult()
+		if key.code == "Enter":
+			name = self._command.strip().casefold().lstrip("/")
+			self._command = None
+			if name in {"help", "?"}:
+				return PageResult(action=PageAction.PUSH, next_page=help_page())
+			if name in {"quit", "q"}:
+				return PageResult(action=PageAction.QUIT)
+			if name:
+				self._command = ""
+				self._command_error = f"Unknown command: /{name} — help quit"
+			return PageResult()
+		if not key.ctrl and not key.alt and len(key.code) == 1 and key.code.isprintable() and not key.code.isspace():
+			self._command_error = None
+			self._command += key.code
 		return PageResult()
diff --git a/pygittools/src/pygittools/tui/pages/projects.py b/pygittools/src/pygittools/tui/pages/projects.py
index 3f48edf..2064f2c 100644
--- a/pygittools/src/pygittools/tui/pages/projects.py
+++ b/pygittools/src/pygittools/tui/pages/projects.py
@@ -5,7 +5,7 @@ from __future__ import annotations
 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.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up, move_hint
 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
@@ -35,7 +35,7 @@ class ProjectsPage:
 			return self._error
 		if not self._projects:
 			return "No projects found — Esc back"
-		return "Enter open project — j/k move — Esc back"
+		return f"Enter open project — {move_hint()} — Esc back"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0:
diff --git a/pygittools/src/pygittools/tui/pages/task_editor.py b/pygittools/src/pygittools/tui/pages/task_editor.py
index 5b7d99c..724d5f6 100644
--- a/pygittools/src/pygittools/tui/pages/task_editor.py
+++ b/pygittools/src/pygittools/tui/pages/task_editor.py
@@ -33,7 +33,7 @@ from pygittools.tasks import (
 )
 from pygittools.tasks_query import BOARD_STATUS_ORDER, get_task_view
 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.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up, move_hint
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 _FieldName = Literal["title", "description", "status", "priority", "assignee", "due_date"]
@@ -92,17 +92,18 @@ class TaskEditorPage:
 		if self._error:
 			return self._error
 		field_index = self._list_state.selected or 0
+		move = move_hint()
 		if self._task_ref is not None:
 			if field_index == len(_FIELDS):
-				return "Enter remove task (local) — j/k move — q or Esc back"
+				return f"Enter remove task (local) — {move} — q or Esc back"
 			if field_index == len(_FIELDS) + 1:
-				return "Enter remove task (remote & local) — j/k move — q or Esc back"
+				return f"Enter remove task (remote & local) — {move} — q or Esc back"
 		if self._input_mode == "input":
 			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 f"{move} 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"
+		return f"Enter edit field — {move} field — q or Esc back"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0:
diff --git a/pygittools/src/pygittools/tui/pages/worktree_add.py b/pygittools/src/pygittools/tui/pages/worktree_add.py
index ea5b61d..0611034 100644
--- a/pygittools/src/pygittools/tui/pages/worktree_add.py
+++ b/pygittools/src/pygittools/tui/pages/worktree_add.py
@@ -18,7 +18,7 @@ from pyratatui import (
 
 from pygittools.tui.git_colors import current_theme
 from pygittools.tui.git_worktrees import add_worktree
-from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_esc, is_quit, is_up, move_hint
 from pygittools.tui.types import PageAction, PageContext, PageResult
 
 _Field = Literal["path", "branch"]
@@ -46,7 +46,7 @@ class WorktreeAddPage:
 	def status_text(self) -> str:
 		if self._error:
 			return self._error
-		return "Enter submit — j/k switch field — Esc back"
+		return f"Enter submit — {move_hint()} switch field — Esc back"
 
 	def draw(self, frame: Frame, area: Rect) -> None:
 		if area.height <= 0:
diff --git a/pygittools/src/pygittools/tui/pages_test.py b/pygittools/src/pygittools/tui/pages_test.py
index 19904e4..0656534 100644
--- a/pygittools/src/pygittools/tui/pages_test.py
+++ b/pygittools/src/pygittools/tui/pages_test.py
@@ -19,7 +19,7 @@ from pygittools.tui.pages.home import HomePage, home_page
 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.types import PageAction, PageContext, PageResult
 
 
 def test_home_page_on_enter_and_quit(page_ctx: PageContext) -> None:
@@ -75,7 +75,7 @@ def test_home_page_handle_key_skips_refresh(page_ctx: PageContext) -> None:
 		patch.object(page.tasks, "refresh") as tasks_refresh,
 		patch.object(page.worktrees, "refresh") as worktrees_refresh,
 	):
-		page.handle_key(key("j"))
+		page.handle_key(key("Down"))
 	changes_refresh.assert_not_called()
 	log_refresh.assert_not_called()
 	tasks_refresh.assert_not_called()
@@ -85,6 +85,7 @@ def test_home_page_handle_key_skips_refresh(page_ctx: PageContext) -> None:
 def test_home_page_commit_typing_skips_refresh(page_ctx: PageContext) -> None:
 	page = HomePage()
 	page.on_enter(page_ctx)
+	_slash(page, "commit")
 	page.handle_key(key("a"))
 	with (
 		patch.object(page.changes, "refresh") as changes_refresh,
@@ -103,12 +104,14 @@ def test_home_page_commit_typing_skips_refresh(page_ctx: PageContext) -> None:
 def test_home_page_commit_refreshes_log(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (True, ""),
+		lambda _repo, _msg, *, amend=False: (True, ""),
 	)
 	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
 	page = HomePage()
 	page.on_enter(ctx)
 	page.changes.set_commit_message("amend note")
+	page.handle_key(key("Enter"))
+	page.handle_key(key("Tab"))
 	with (
 		patch.object(page.log, "refresh") as log_refresh,
 		patch.object(page.tasks, "refresh") as tasks_refresh,
@@ -123,12 +126,14 @@ def test_home_page_commit_refreshes_log(committed_repo: Repository, monkeypatch:
 def test_home_page_input_commit_refreshes_log(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
 	monkeypatch.setattr(
 		"pygittools.tui.changes_list.commit_staged",
-		lambda _repo, _msg: (True, ""),
+		lambda _repo, _msg, *, amend=False: (True, ""),
 	)
 	ctx = PageContext(repo=committed_repo, repo_path=Path(committed_repo.path))
 	page = HomePage()
 	page.on_enter(ctx)
+	_slash(page, "commit")
 	page.handle_key(key("m"))
+	page.handle_key(key("Tab"))
 	with (
 		patch.object(page.log, "refresh") as log_refresh,
 		patch.object(page.tasks, "refresh") as tasks_refresh,
@@ -159,14 +164,14 @@ def test_home_page_draw_and_tabs(committed_repo: Repository) -> None:
 def test_home_page_switch_to_input(page_ctx: PageContext) -> None:
 	page = home_page()
 	page.on_enter(page_ctx)
-	page.handle_key(key("a"))
+	_slash(page, "commit")
 	assert "Esc" in page.status_text()
 
 
 def test_home_page_types_q_in_commit_input(page_ctx: PageContext) -> None:
 	page = HomePage()
 	page.on_enter(page_ctx)
-	page.handle_key(key("a"))
+	_slash(page, "commit")
 	result = page.handle_key(key("q"))
 	assert result.action == PageAction.NONE
 	assert "q" in page.changes.commit_message
@@ -202,7 +207,7 @@ def test_task_editor_types_q_in_input(page_ctx: PageContext) -> None:
 def test_home_page_push_help(page_ctx: PageContext) -> None:
 	page = home_page()
 	page.on_enter(page_ctx)
-	result = page.handle_key(key("?"))
+	result = _slash(page, "help")
 	assert result.action == PageAction.PUSH
 	assert isinstance(result.next_page, HelpPage)
 
@@ -233,7 +238,7 @@ def test_home_page_actions_row(repo: Repository) -> None:
 def test_home_page_push_actions(page_ctx: PageContext) -> None:
 	page = home_page()
 	page.on_enter(page_ctx)
-	result = page.handle_key(key("!"))
+	result = _slash(page, "actions")
 	assert result.action == PageAction.PUSH
 	assert isinstance(result.next_page, ActionsPage)
 
@@ -246,7 +251,7 @@ def test_home_page_draws_detached_head(committed_repo: Repository) -> None:
 	page.on_enter(ctx)
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert current_branch_name(committed_repo) == "(detached)"
-	assert "j/k move" in page.status_text()
+	assert "arrows move" in page.status_text()
 	page.set_active_tab("log")
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	assert "No commits" not in page.status_text()
@@ -262,7 +267,7 @@ def test_empty_repo_can_create_branch_and_commit(repo: Repository) -> None:
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 	page.set_active_tab("log")
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
-	assert page.status_text() == "No commits — j/k move"
+	assert page.status_text() == "No commits — arrows move"
 	page.set_active_tab("status")
 	page.draw(MagicMock(), Rect(0, 0, 80, 20))
 
@@ -288,9 +293,11 @@ def test_empty_repo_can_create_branch_and_commit(repo: Repository) -> None:
 	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())
+	_press_until(page, key("Up"), lambda hint: "edit" in hint.casefold())
+	page.handle_key(key("Enter"))
 	for char in "init":
 		page.handle_key(key(char))
+	page.handle_key(key("Tab"))
 	page.handle_key(key("Enter"))
 
 	assert not repo.head_is_unborn, page.changes.status_hint()
@@ -311,6 +318,13 @@ def _press_until(page: HomePage, press: object, ready: Callable[[str], bool]) ->
 	raise AssertionError(page.status_text())
 
 
+def _slash(page: HomePage | NoRepoPage, command: str) -> PageResult:
+	page.handle_key(key("/"))
+	for char in command:
+		page.handle_key(key(char))
+	return page.handle_key(key("Enter"))
+
+
 def test_home_page_draw_without_context() -> None:
 	page = home_page()
 	page.draw(MagicMock(), Rect(0, 0, 80, 10))
@@ -321,7 +335,7 @@ def test_no_repo_page(tmp_path: Path) -> None:
 	page.on_enter(PageContext(repo=MagicMock(), repo_path=tmp_path))  # type: ignore[arg-type]
 	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
+	assert _slash(page, "help").action == PageAction.PUSH
 	page.handle_key(key("Null"))
 
 
diff --git a/pygittools/src/pygittools/tui/tasks_list.py b/pygittools/src/pygittools/tui/tasks_list.py
index 8c64148..6e68d98 100644
--- a/pygittools/src/pygittools/tui/tasks_list.py
+++ b/pygittools/src/pygittools/tui/tasks_list.py
@@ -17,7 +17,7 @@ from pygittools.tasks_query import (
 	list_boards,
 )
 from pygittools.tui.git_colors import current_theme, focus_style
-from pygittools.tui.keys import KeyLike, is_down, is_enter, is_left, is_right, is_space, is_up
+from pygittools.tui.keys import KeyLike, board_hint, is_down, is_enter, is_left, is_right, is_space, is_up, move_hint
 from pygittools.tui.types import PageAction, PageResult
 
 
@@ -229,14 +229,15 @@ class TasksBoardList:
 			return "No task boards — use pygittools tasks to create one"
 		if not self._rows:
 			return "No tasks — use (+ New Task) to create one"
+		move = move_hint()
 		row = self._rows[self._list_state.selected or 0]
 		if row.kind == "board":
-			return "H/L switch board — j/k move"
+			return f"{board_hint()} — {move}"
 		if row.kind == "status_header":
-			return "Enter collapse — Space collapse — j/k move"
+			return f"Enter collapse — Space collapse — {move}"
 		if row.kind == "new_task":
-			return "Enter create task — j/k move"
-		return "Enter edit task — j/k move"
+			return f"Enter create task — {move}"
+		return f"Enter edit task — {move}"
 
 
 def _default_board_index(boards: list[BoardView]) -> int:
diff --git a/pygittools/src/pygittools/tui/worktree.py b/pygittools/src/pygittools/tui/worktree.py
index 5ca6abd..5c1db14 100644
--- a/pygittools/src/pygittools/tui/worktree.py
+++ b/pygittools/src/pygittools/tui/worktree.py
@@ -93,21 +93,30 @@ def _unstage_index_path(index: Index, head_tree: Tree | None, path: str) -> None
 		index.remove(path)
 
 
-def commit_staged(repo: Repository, message: str) -> tuple[bool, str]:
-	"""Create a commit via the git CLI so client hooks run. Returns (ok, error)."""
+def commit_staged(repo: Repository, message: str, *, amend: bool = False) -> tuple[bool, str]:
+	"""Create or amend a commit via the git CLI so client hooks run. Returns (ok, error)."""
 	trimmed = message.strip()
 	if not trimmed:
 		return False, "Commit message is empty"
 	workdir = repo.workdir
 	if workdir is None:
 		return False, "Repository has no working directory"
-	staged, _, _ = categorize_status(repo)
-	if not staged:
-		return False, "Nothing staged to commit"
-
+	if amend:
+		if repo.head_is_unborn:
+			return False, "Nothing to amend"
+	else:
+		staged, _, _ = categorize_status(repo)
+		if not staged:
+			return False, "Nothing staged to commit"
+
+	command = [git_executable(), "commit"]
+	if amend:
+		command.append("--amend")
+	command.extend(["-F", "-"])
 	result = subprocess.run(
-		[git_executable(), "commit", "-m", trimmed],
+		command,
 		cwd=workdir,
+		input=f"{trimmed}\n",
 		capture_output=True,
 		text=True,
 		check=False,
diff --git a/pygittools/src/pygittools/tui/worktree_test.py b/pygittools/src/pygittools/tui/worktree_test.py
index abbb731..97a5f43 100644
--- a/pygittools/src/pygittools/tui/worktree_test.py
+++ b/pygittools/src/pygittools/tui/worktree_test.py
@@ -161,6 +161,18 @@ def test_commit_staged_success(committed_repo: Repository) -> None:
 	assert error == ""
 
 
+def test_commit_amend_success(committed_repo: Repository) -> None:
+	ok, error = commit_staged(committed_repo, "initial\n\nbody", amend=True)
+	assert ok is True
+	assert error == ""
+
+
+def test_commit_amend_requires_head(repo: Repository) -> None:
+	ok, error = commit_staged(repo, "msg", amend=True)
+	assert ok is False
+	assert error == "Nothing to amend"
+
+
 def test_commit_staged_surfaces_git_error(committed_repo: Repository, monkeypatch: pytest.MonkeyPatch) -> None:
 	path = Path(committed_repo.workdir) / "new.txt"
 	path.write_text("x\n", encoding="utf-8")
diff --git a/pygittools/src/pygittools/tui/worktrees_list.py b/pygittools/src/pygittools/tui/worktrees_list.py
index 41865bf..867f53f 100644
--- a/pygittools/src/pygittools/tui/worktrees_list.py
+++ b/pygittools/src/pygittools/tui/worktrees_list.py
@@ -11,7 +11,7 @@ from pyratatui import Frame, List, ListItem, ListState, Rect
 
 from pygittools.tui.git_colors import current_theme, focus_style
 from pygittools.tui.git_worktrees import WorktreeInfo, list_worktrees, remove_worktree
-from pygittools.tui.keys import KeyLike, is_down, is_enter, is_up
+from pygittools.tui.keys import KeyLike, is_down, is_enter, is_up, move_hint
 from pygittools.tui.pages.worktree_add import worktree_add_page
 from pygittools.tui.types import PageAction, PageResult
 
@@ -76,7 +76,7 @@ class WorktreesList:
 		)
 
 	def handle_nav_key(self, key: KeyLike, repo: Repository) -> PageResult | None:
-		if self._error is not None and key.code not in {"Up", "Down", "k", "K", "j", "J"}:
+		if self._error is not None and not (is_up(key) or is_down(key)):
 			self._error = None
 		if is_up(key):
 			selected = self._list_state.selected or 0
@@ -113,14 +113,15 @@ class WorktreesList:
 			return self._error
 		if not self._rows:
 			return "No worktrees"
+		move = move_hint()
 		row = self._rows[self._list_state.selected or 0]
 		if row.kind == "add":
-			return "Enter add worktree — j/k move"
+			return f"Enter add worktree — {move}"
 		if row.info is not None and self._current_path is not None:
 			if row.info.path.resolve() == self._current_path.resolve():
-				return "Active worktree — d remove other worktrees — j/k move"
-			return "Enter open worktree — d remove — j/k move"
-		return "j/k move"
+				return f"Active worktree — d remove other worktrees — {move}"
+			return f"Enter open worktree — d remove — {move}"
+		return move
 
 
 def _row_label(row: _Row, *, current: Path | None, width: int) -> str:
