diff --git a/src/tui/components/hotkey-hint.test.tsx b/src/tui/components/hotkey-hint.test.tsx
new file mode 100644
index 0000000..0c5b131
--- /dev/null
+++ b/src/tui/components/hotkey-hint.test.tsx
@@ -0,0 +1,105 @@
+import { render } from "ink-testing-library";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import React from "react";
+
+import type { ApprovalRequest } from "../../approval/approval-gate.js";
+import { fakeSession } from "../test-fixtures.js";
+import { createInitialTuiState, type TuiState } from "../tui-state.js";
+import { HotkeyHint } from "./hotkey-hint.js";
+
+const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
+
+// Literal spellings, deliberately NOT derived from process.platform the
+// way the component does it — otherwise the assertion would be true by
+// construction on every platform.
+const MAC_SCROLL_KEY = "fn+↑↓";
+const OTHER_SCROLL_KEY = "pgup/pgdn";
+const SCROLL_KEY_PATTERN = /fn\+↑↓|pgup\/pgdn/;
+
+function renderHint(state: TuiState): string {
+ const { lastFrame, unmount } = render();
+ const out = (lastFrame() ?? "").replace(ANSI, "");
+ unmount();
+ return out;
+}
+
+function chatState(overrides: Partial = {}): TuiState {
+ return {
+ ...createInitialTuiState(fakeSession()),
+ uiMode: "chat" as const,
+ ...overrides,
+ };
+}
+
+function fakeApproval(): ApprovalRequest {
+ return {
+ approvalId: "appr-1",
+ sessionId: "sess-1",
+ tool: "bash",
+ reason: "wants to run a command",
+ preview: "rm -rf ./dist",
+ };
+}
+
+describe("HotkeyHint scroll chip", () => {
+ it("advertises the chat-scroll key in the idle chat footer", () => {
+ const out = renderHint(chatState());
+ expect(out).toMatch(SCROLL_KEY_PATTERN);
+ expect(out).toContain("scroll");
+ // The scroll chip took ctrl+b's slot to stay within the 6-chip row.
+ expect(out).not.toContain("ctrl+b");
+ });
+
+ it("advertises the chat-scroll key while a turn is running", () => {
+ const out = renderHint(chatState({ status: "running" }));
+ expect(out).toMatch(SCROLL_KEY_PATTERN);
+ expect(out).toContain("scroll");
+ });
+
+ it("keeps modal footers free of the scroll chip", () => {
+ const out = renderHint(chatState({ slashPaletteOpen: true }));
+ expect(out).not.toContain("scroll");
+ });
+
+ it("keeps the approval footer (y/n/esc) free of the scroll chip", () => {
+ const out = renderHint(chatState({ pendingApproval: fakeApproval() }));
+ expect(out).toContain("approve");
+ expect(out).toContain("deny");
+ expect(out).toContain("abort run");
+ expect(out).not.toContain("scroll");
+ expect(out).not.toMatch(SCROLL_KEY_PATTERN);
+ });
+});
+
+describe("HotkeyHint scroll key spelling per platform", () => {
+ const realPlatform = process.platform;
+
+ afterEach(() => {
+ Object.defineProperty(process, "platform", { value: realPlatform });
+ vi.resetModules();
+ });
+
+ // SCROLL_KEY is resolved at module load, so each case re-imports the
+ // component after stubbing process.platform.
+ async function renderIdleFooterOn(platform: NodeJS.Platform): Promise {
+ Object.defineProperty(process, "platform", { value: platform });
+ vi.resetModules();
+ const fresh = await import("./hotkey-hint.js");
+ const { lastFrame, unmount } = render();
+ const out = (lastFrame() ?? "").replace(ANSI, "");
+ unmount();
+ return out;
+ }
+
+ it("darwin spells the scroll key as fn+arrows", async () => {
+ const out = await renderIdleFooterOn("darwin");
+ expect(out).toContain(MAC_SCROLL_KEY);
+ expect(out).not.toContain(OTHER_SCROLL_KEY);
+ });
+
+ it("linux spells the scroll key as pgup/pgdn", async () => {
+ const out = await renderIdleFooterOn("linux");
+ expect(out).toContain(OTHER_SCROLL_KEY);
+ expect(out).not.toContain(MAC_SCROLL_KEY);
+ });
+});
diff --git a/src/tui/components/hotkey-hint.tsx b/src/tui/components/hotkey-hint.tsx
index 945870f..1737510 100644
--- a/src/tui/components/hotkey-hint.tsx
+++ b/src/tui/components/hotkey-hint.tsx
@@ -14,6 +14,13 @@ interface HotkeyChip {
readonly label: string;
}
+/**
+ * Platform-aware label for the chat-scroll key. The physical key is
+ * PageUp; Mac keyboards reach it via Fn+Up, and that is the spelling
+ * Mac users actually recognise.
+ */
+const SCROLL_KEY = process.platform === "darwin" ? "fn+\u2191\u2193" : "pgup/pgdn";
+
/**
* Bottom hint strip: surfaces the keybindings that are meaningful in
* the current state so the user never has to guess. We cap to ~6 chips
@@ -59,7 +66,10 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
];
}
if (state.status === "running") {
+ // A long streaming answer is exactly when the operator wants to
+ // scroll back, so the hint rides along with abort.
return [
+ { key: SCROLL_KEY, label: "scroll" },
{ key: "esc", label: "abort" },
{
key: "ctrl+c",
@@ -91,11 +101,14 @@ function resolveChips(state: TuiState, ctrlCArmed: boolean): HotkeyChip[] {
},
];
}
+ // Six chips is the cap for one row on narrow terminals. The scroll
+ // hint replaces ctrl+b: Observe stays reachable via /observe, while
+ // scrolling had no visible entry point at all.
return [
{ key: "enter", label: "send" },
{ key: "alt+enter", label: "newline" },
{ key: "tab", label: "sidebar" },
- { key: "ctrl+b", label: "open Observe" },
+ { key: SCROLL_KEY, label: "scroll" },
{ key: "/", label: "commands" },
{
key: "ctrl+c",