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",