feat(FN-1471): add global keyboard shortcuts hook to TUI
- Create useGlobalShortcuts hook for centralized keyboard shortcut handling at app root - Add FocusGuardRef module-level ref for tracking text input focus state - Implement HelpOverlay component displaying available keyboard shortcuts - Add Ctrl+C (emergency exit), q (quit), ?/h (help toggle), 1-5 (screen switch) shortcuts - Focus guard prevents shortcuts when text input is focused (except Ctrl+C) - Update ScreenRouter with controlled/uncontrolled mode support - Wire global shortcuts into demo app with help overlay integration - Add comprehensive tests for useGlobalShortcuts and HelpOverlay - Update README with global shortcuts documentation and usage examples
This commit is contained in:
497
packages/tui/src/__tests__/global-shortcuts.test.tsx
Normal file
497
packages/tui/src/__tests__/global-shortcuts.test.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Tests for global keyboard shortcuts hook.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, Box, Text } from "ink";
|
||||
import { useGlobalShortcuts, HelpOverlay, FocusGuardRef, type ScreenId } from "../hooks/use-global-shortcuts";
|
||||
import type { Key } from "ink";
|
||||
|
||||
// Track captured handlers for test assertions
|
||||
let capturedUseInputHandlers: ((input: string, key: Key) => void)[] = [];
|
||||
let capturedExitFn: (() => void) | undefined;
|
||||
|
||||
// Mock ink hooks to avoid raw mode errors in tests
|
||||
vi.mock("ink", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ink")>();
|
||||
return {
|
||||
...actual,
|
||||
useInput: vi.fn((handler: (input: string, key: Key) => void) => {
|
||||
capturedUseInputHandlers.push(handler);
|
||||
}),
|
||||
useApp: vi.fn().mockReturnValue({
|
||||
exit: vi.fn(() => {
|
||||
capturedExitFn?.();
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("useGlobalShortcuts", () => {
|
||||
beforeEach(() => {
|
||||
capturedUseInputHandlers = [];
|
||||
capturedExitFn = undefined;
|
||||
// Reset focus guard ref
|
||||
FocusGuardRef.isFocused = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedUseInputHandlers = [];
|
||||
FocusGuardRef.isFocused = false;
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("starts with help overlay hidden", async () => {
|
||||
let capturedHelpVisible: boolean | undefined;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible } = useGlobalShortcuts();
|
||||
capturedHelpVisible = helpVisible;
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(capturedHelpVisible).toBe(false);
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("provides toggleHelp function", async () => {
|
||||
let toggleHelpFn: (() => void) | undefined;
|
||||
|
||||
function TestComponent() {
|
||||
const { toggleHelp } = useGlobalShortcuts();
|
||||
toggleHelpFn = toggleHelp;
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(toggleHelpFn).toBeDefined();
|
||||
expect(typeof toggleHelpFn).toBe("function");
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("provides hideHelp function", async () => {
|
||||
let hideHelpFn: (() => void) | undefined;
|
||||
|
||||
function TestComponent() {
|
||||
const { hideHelp } = useGlobalShortcuts();
|
||||
hideHelpFn = hideHelp;
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(hideHelpFn).toBeDefined();
|
||||
expect(typeof hideHelpFn).toBe("function");
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpVisible state management", () => {
|
||||
it("toggleHelp toggles helpVisible from false to true", async () => {
|
||||
let helpVisibleValues: boolean[] = [];
|
||||
let toggleFn: (() => void) | undefined;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible, toggleHelp } = useGlobalShortcuts();
|
||||
helpVisibleValues.push(helpVisible);
|
||||
toggleFn = toggleHelp;
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Initial state should be false
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
|
||||
|
||||
// Call toggle
|
||||
toggleFn?.();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// After toggle, state should be true
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("toggleHelp toggles helpVisible from true to false", async () => {
|
||||
let helpVisibleValues: boolean[] = [];
|
||||
let toggleFn: (() => void) | undefined;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible, toggleHelp } = useGlobalShortcuts();
|
||||
helpVisibleValues.push(helpVisible);
|
||||
toggleFn = toggleHelp;
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// First toggle
|
||||
toggleFn?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Second toggle should bring back to false
|
||||
toggleFn?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// State should be false again
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("screen change callback", () => {
|
||||
it("accepts onScreenChange option", async () => {
|
||||
const onScreenChange = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts({ onScreenChange });
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(onScreenChange).toBeDefined();
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("calls onScreenChange with screenId when number key is pressed", async () => {
|
||||
const onScreenChange = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts({ onScreenChange });
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Get the registered input handler
|
||||
expect(capturedUseInputHandlers.length).toBeGreaterThan(0);
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Simulate pressing "1"
|
||||
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(onScreenChange).toHaveBeenCalledWith("board");
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("calls onScreenChange with correct screen IDs for keys 1-5", async () => {
|
||||
const onScreenChange = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts({ onScreenChange });
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const expectedScreens: ScreenId[] = ["board", "detail", "activity", "agents", "settings"];
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
onScreenChange.mockClear();
|
||||
const key = String(i + 1);
|
||||
|
||||
handler(key, { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(onScreenChange).toHaveBeenCalledWith(expectedScreens[i]);
|
||||
}
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus guard", () => {
|
||||
it("prevents screen change when FocusGuardRef.isFocused is true", async () => {
|
||||
const onScreenChange = vi.fn();
|
||||
// Set focus guard BEFORE render
|
||||
FocusGuardRef.isFocused = true;
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts({ onScreenChange });
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Simulate pressing "1" - should NOT trigger screen change when focused
|
||||
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// onScreenChange should NOT be called when focused
|
||||
expect(onScreenChange).not.toHaveBeenCalled();
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("allows screen change when FocusGuardRef.isFocused is false", async () => {
|
||||
const onScreenChange = vi.fn();
|
||||
// Ensure focus guard is NOT set
|
||||
FocusGuardRef.isFocused = false;
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts({ onScreenChange });
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Simulate pressing "1" - should trigger screen change when not focused
|
||||
handler("1", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// onScreenChange SHOULD be called when not focused
|
||||
expect(onScreenChange).toHaveBeenCalledWith("board");
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ctrl+C exit", () => {
|
||||
it("calls exit when Ctrl+C is pressed", async () => {
|
||||
let exitCalled = false;
|
||||
capturedExitFn = () => {
|
||||
exitCalled = true;
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts();
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Simulate Ctrl+C
|
||||
handler("c", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: true, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("q exit (focus guard)", () => {
|
||||
it("calls exit when q is pressed and FocusGuardRef.isFocused is false", async () => {
|
||||
let exitCalled = false;
|
||||
capturedExitFn = () => {
|
||||
exitCalled = true;
|
||||
};
|
||||
FocusGuardRef.isFocused = false;
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts();
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(exitCalled).toBe(true);
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("does not call exit when q is pressed but FocusGuardRef.isFocused is true", async () => {
|
||||
let exitCalled = false;
|
||||
capturedExitFn = () => {
|
||||
exitCalled = true;
|
||||
};
|
||||
// Set focus guard - input is focused
|
||||
FocusGuardRef.isFocused = true;
|
||||
|
||||
function TestComponent() {
|
||||
useGlobalShortcuts();
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(exitCalled).toBe(false);
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("help toggle (? and h)", () => {
|
||||
it("triggers toggle when ? is pressed and FocusGuardRef.isFocused is false", async () => {
|
||||
let helpVisibleValues: boolean[] = [];
|
||||
FocusGuardRef.isFocused = false;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible } = useGlobalShortcuts();
|
||||
helpVisibleValues.push(helpVisible);
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Press "?" to toggle
|
||||
handler("?", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Help should be visible after toggle
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("triggers toggle when h is pressed and FocusGuardRef.isFocused is false", async () => {
|
||||
let helpVisibleValues: boolean[] = [];
|
||||
FocusGuardRef.isFocused = false;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible } = useGlobalShortcuts();
|
||||
helpVisibleValues.push(helpVisible);
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Press "h" to toggle
|
||||
handler("h", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Help should be visible after toggle
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(true);
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("does not trigger toggle when ? is pressed but FocusGuardRef.isFocused is true", async () => {
|
||||
let helpVisibleValues: boolean[] = [];
|
||||
// Set focus guard - input is focused
|
||||
FocusGuardRef.isFocused = true;
|
||||
|
||||
function TestComponent() {
|
||||
const { helpVisible } = useGlobalShortcuts();
|
||||
helpVisibleValues.push(helpVisible);
|
||||
return <Text>Test</Text>;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[0];
|
||||
|
||||
// Press "?" - should NOT toggle when focused
|
||||
handler("?", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Help should still be hidden
|
||||
expect(helpVisibleValues[helpVisibleValues.length - 1]).toBe(false);
|
||||
|
||||
instance.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HelpOverlay", () => {
|
||||
it("renders without crashing", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
const instance = render(<HelpOverlay onClose={onClose} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(() => instance.unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
return <HelpOverlay onClose={onClose} />;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[capturedUseInputHandlers.length - 1];
|
||||
|
||||
handler("", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: true, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("calls onClose when q is pressed", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
return <HelpOverlay onClose={onClose} />;
|
||||
}
|
||||
|
||||
const instance = render(<TestComponent />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = capturedUseInputHandlers[capturedUseInputHandlers.length - 1];
|
||||
|
||||
handler("q", { upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, pageDown: false, pageUp: false, home: false, end: false, return: false, escape: false, ctrl: false, shift: false, tab: false, backspace: false, delete: false, meta: false, super: false, hyper: false, capsLock: false, numLock: false });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
instance.unmount();
|
||||
});
|
||||
|
||||
it("displays keyboard shortcuts", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
const instance = render(<HelpOverlay onClose={onClose} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The component should render without error
|
||||
expect(() => instance.unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,21 @@ export interface ScreenComponentProps {
|
||||
* Props for the ScreenRouter component.
|
||||
*/
|
||||
export interface ScreenRouterProps {
|
||||
/**
|
||||
* Initial screen to display on mount.
|
||||
* @default "board"
|
||||
*/
|
||||
initialScreen?: ScreenId;
|
||||
/**
|
||||
* Callback invoked when the user navigates to a different screen.
|
||||
* Use this to sync with external state (e.g., global shortcuts).
|
||||
*/
|
||||
onScreenChange?: (screenId: ScreenId) => void;
|
||||
/**
|
||||
* Externally controlled active screen.
|
||||
* When provided, the router uses this instead of internal state.
|
||||
*/
|
||||
activeScreen?: ScreenId;
|
||||
/**
|
||||
* Render function for each screen.
|
||||
* Receives the screen ID and should return the screen component.
|
||||
@@ -79,21 +94,32 @@ export interface ScreenRouterProps {
|
||||
* </ScreenRouter>
|
||||
* ```
|
||||
*/
|
||||
export function ScreenRouter({ children }: ScreenRouterProps): React.ReactNode {
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>("board");
|
||||
export function ScreenRouter({ children, initialScreen = "board", onScreenChange, activeScreen: externalActiveScreen }: ScreenRouterProps): React.ReactNode {
|
||||
// Use external state if provided, otherwise use internal state
|
||||
const [internalActiveScreen, setInternalActiveScreen] = useState<ScreenId>(initialScreen);
|
||||
const activeScreen = externalActiveScreen ?? internalActiveScreen;
|
||||
|
||||
// Navigate to a specific screen by index
|
||||
const navigateToIndex = useCallback((index: number) => {
|
||||
const normalizedIndex = ((index % SCREENS.length) + SCREENS.length) % SCREENS.length;
|
||||
setActiveScreen(SCREENS[normalizedIndex].id);
|
||||
}, []);
|
||||
const newScreen = SCREENS[normalizedIndex].id;
|
||||
// Only update internal state if not externally controlled
|
||||
if (externalActiveScreen === undefined) {
|
||||
setInternalActiveScreen(newScreen);
|
||||
}
|
||||
onScreenChange?.(newScreen);
|
||||
}, [externalActiveScreen, onScreenChange]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Number keys 1-5 for direct selection
|
||||
const num = parseInt(input, 10);
|
||||
if (num >= 1 && num <= SCREENS.length) {
|
||||
setActiveScreen(SCREENS[num - 1].id);
|
||||
const newScreen = SCREENS[num - 1].id;
|
||||
if (externalActiveScreen === undefined) {
|
||||
setInternalActiveScreen(newScreen);
|
||||
}
|
||||
onScreenChange?.(newScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,3 +7,5 @@ export type { UseTasksResult } from "./use-tasks.js";
|
||||
|
||||
export { useActivityLog } from "./use-activity-log.js";
|
||||
export type { UseActivityLogOptions, UseActivityLogResult } from "./use-activity-log.js";
|
||||
|
||||
export { useGlobalShortcuts, HelpOverlay, type UseGlobalShortcutsOptions, type UseGlobalShortcutsResult, type HelpOverlayProps } from "./use-global-shortcuts.jsx";
|
||||
|
||||
228
packages/tui/src/hooks/use-global-shortcuts.tsx
Normal file
228
packages/tui/src/hooks/use-global-shortcuts.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* useGlobalShortcuts - Centralized keyboard shortcut handler for the TUI app.
|
||||
*
|
||||
* Handles global shortcuts in one place with proper focus-guard logic:
|
||||
* - Ctrl+C always exits cleanly
|
||||
* - q exits when no text input is focused
|
||||
* - ?/h toggles the help overlay
|
||||
* - 1-5 switch screens via callback
|
||||
*
|
||||
* This hook should be used at the top app/screen-router level so all screens
|
||||
* share consistent behavior without scattering duplicate handlers.
|
||||
*
|
||||
* Focus Guard: Use the shared FocusGuardRef to track text input focus state.
|
||||
* Import FocusGuardRef from this module and set FocusGuardRef.isFocused = true/false
|
||||
* in text input onFocus/onBlur handlers.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useInput, useApp } from "ink";
|
||||
import { SCREENS, type ScreenId } from "../components/screen-router.js";
|
||||
|
||||
/**
|
||||
* Shared ref for tracking text input focus state globally.
|
||||
* Set FocusGuardRef.isFocused = true when a text input gains focus,
|
||||
* and FocusGuardRef.isFocused = false when it loses focus.
|
||||
*
|
||||
* This is a simple module-level ref that any component can import and modify.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { FocusGuardRef } from "./use-global-shortcuts";
|
||||
*
|
||||
* function MyTextInput() {
|
||||
* return (
|
||||
* <Input
|
||||
* onFocus={() => { FocusGuardRef.isFocused = true; }}
|
||||
* onBlur={() => { FocusGuardRef.isFocused = false; }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const FocusGuardRef = {
|
||||
isFocused: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for the useGlobalShortcuts hook.
|
||||
*/
|
||||
export interface UseGlobalShortcutsOptions {
|
||||
/**
|
||||
* Callback invoked when the user presses a number key (1-5) to switch screens.
|
||||
* Receives the screen ID to switch to.
|
||||
*/
|
||||
onScreenChange?: (screenId: ScreenId) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value from the useGlobalShortcuts hook.
|
||||
*/
|
||||
export interface UseGlobalShortcutsResult {
|
||||
/** Whether the help overlay is currently visible */
|
||||
helpVisible: boolean;
|
||||
/** Manually toggle the help overlay visibility */
|
||||
toggleHelp: () => void;
|
||||
/** Hide the help overlay */
|
||||
hideHelp: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that handles global keyboard shortcuts for the TUI.
|
||||
*
|
||||
* This hook should be placed at the app root level (above the ScreenRouter) to ensure
|
||||
* all screens receive consistent shortcut handling. It centralizes all global shortcuts
|
||||
* to prevent conflicts and duplication.
|
||||
*
|
||||
* Focus guard behavior:
|
||||
* - Ctrl+C always exits (emergency exit)
|
||||
* - q exits only when no text input is focused (via FocusGuardRef)
|
||||
* - ?/h toggles help only when no text input is focused
|
||||
* - Number keys (1-5) for screen switching are handled by the ScreenRouter internally
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.onScreenChange - Optional callback for screen changes triggered by number keys
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function App() {
|
||||
* const { helpVisible, toggleHelp } = useGlobalShortcuts();
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {helpVisible && <HelpOverlay onClose={toggleHelp} />}
|
||||
* <ScreenRouter>
|
||||
* {({ activeScreen }) => (
|
||||
* // Screen content...
|
||||
* )}
|
||||
* </ScreenRouter>
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}): UseGlobalShortcutsResult {
|
||||
const { onScreenChange } = options;
|
||||
const { exit } = useApp();
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
|
||||
// Toggle help overlay
|
||||
const toggleHelp = useCallback(() => {
|
||||
setHelpVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Hide help overlay
|
||||
const hideHelp = useCallback(() => {
|
||||
setHelpVisible(false);
|
||||
}, []);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Ctrl+C always exits cleanly (emergency exit)
|
||||
if (key.ctrl && input.toLowerCase() === "c") {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// q - exit only when no text input is focused
|
||||
if (input.toLowerCase() === "q" && !FocusGuardRef.isFocused) {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// ? or h - toggle help overlay (only when not focused)
|
||||
if (!FocusGuardRef.isFocused) {
|
||||
if (input === "?" || input.toLowerCase() === "h") {
|
||||
toggleHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1-5 - screen switching via callback
|
||||
const num = parseInt(input, 10);
|
||||
if (num >= 1 && num <= SCREENS.length) {
|
||||
const screenId = SCREENS[num - 1].id;
|
||||
onScreenChange?.(screenId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true } // Always active to catch global shortcuts
|
||||
);
|
||||
|
||||
// Cleanup: hide help on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setHelpVisible(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
helpVisible,
|
||||
toggleHelp,
|
||||
hideHelp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the HelpOverlay component.
|
||||
*/
|
||||
export interface HelpOverlayProps {
|
||||
/** Callback to close the help overlay */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* HelpOverlay component that displays keyboard shortcuts.
|
||||
*
|
||||
* @param props.onClose - Callback to close the overlay
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <HelpOverlay onClose={() => setHelpVisible(false)} />
|
||||
* ```
|
||||
*/
|
||||
export function HelpOverlay({ onClose }: HelpOverlayProps): React.ReactNode {
|
||||
// Handle Escape and q to close
|
||||
useInput((input, key) => {
|
||||
if (key.escape || input.toLowerCase() === "q") {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ key: "Ctrl+C", description: "Quit (emergency exit)" },
|
||||
{ key: "q", description: "Quit (when no text input is focused)" },
|
||||
{ key: "?", description: "Toggle this help overlay" },
|
||||
{ key: "h", description: "Toggle this help overlay (alternate)" },
|
||||
{ key: "1-5", description: "Switch screens" },
|
||||
{ key: "Tab", description: "Cycle forward through tabs" },
|
||||
{ key: "Shift+Tab", description: "Cycle backward through tabs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
backgroundColor="black"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Keyboard Shortcuts
|
||||
</Text>
|
||||
<Text dimColor>────────────────</Text>
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Text key={shortcut.key}>
|
||||
<Text bold color="white">{shortcut.key.padEnd(12)}</Text>
|
||||
<Text dimColor>{shortcut.description}</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>────────────────</Text>
|
||||
<Text dimColor italic>Press Esc or q to close</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export Box and Text from ink for use in HelpOverlay
|
||||
import { Box, Text } from "ink";
|
||||
@@ -24,10 +24,21 @@ export {
|
||||
type ScreenComponentProps,
|
||||
} from "./components/screen-router.js";
|
||||
|
||||
import React from "react";
|
||||
// Re-export global shortcuts hooks
|
||||
export {
|
||||
useGlobalShortcuts,
|
||||
HelpOverlay,
|
||||
FocusGuardRef,
|
||||
type UseGlobalShortcutsOptions,
|
||||
type UseGlobalShortcutsResult,
|
||||
type HelpOverlayProps,
|
||||
} from "./hooks/use-global-shortcuts.js";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { render, Box, Text } from "ink";
|
||||
import { FusionProvider, useFusion } from "./fusion-context.js";
|
||||
import { ScreenRouter } from "./components/screen-router.js";
|
||||
import { ScreenRouter, type ScreenId } from "./components/screen-router.js";
|
||||
import { useGlobalShortcuts, HelpOverlay } from "./hooks/use-global-shortcuts.js";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/**
|
||||
@@ -37,17 +48,34 @@ import { fileURLToPath } from "url";
|
||||
*/
|
||||
function DemoApp() {
|
||||
const { projectPath } = useFusion();
|
||||
const [activeScreen, setActiveScreen] = useState<ScreenId>("board");
|
||||
|
||||
// Global keyboard shortcuts - handles Ctrl+C, q, ?/h, 1-5
|
||||
const { helpVisible, toggleHelp } = useGlobalShortcuts({
|
||||
onScreenChange: setActiveScreen,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Help Overlay - shown when toggled, displayed at top */}
|
||||
{helpVisible && (
|
||||
<Box marginBottom={1}>
|
||||
<HelpOverlay onClose={toggleHelp} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<Box paddingBottom={1}>
|
||||
<Text bold>Fusion TUI</Text>
|
||||
<Text> | Project: {projectPath}</Text>
|
||||
<Text dimColor> (Press ? for help)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Screen Router */}
|
||||
<ScreenRouter>
|
||||
<ScreenRouter
|
||||
activeScreen={activeScreen}
|
||||
onScreenChange={setActiveScreen}
|
||||
>
|
||||
{({ activeScreen }) => (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{activeScreen === "board" && (
|
||||
|
||||
Reference in New Issue
Block a user