Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/tui/components/hotkey-hint.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<HotkeyHint state={state} />);
const out = (lastFrame() ?? "").replace(ANSI, "");
unmount();
return out;
}

function chatState(overrides: Partial<TuiState> = {}): 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<string> {
Object.defineProperty(process, "platform", { value: platform });
vi.resetModules();
const fresh = await import("./hotkey-hint.js");
const { lastFrame, unmount } = render(<fresh.HotkeyHint state={chatState()} />);
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);
});
});
15 changes: 14 additions & 1 deletion src/tui/components/hotkey-hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down