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:
gsxdsm
2026-04-09 21:19:20 -07:00
parent 591e9d8b01
commit a8f4c3d447
7 changed files with 957 additions and 27 deletions

View 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();
});
});

View File

@@ -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;
}

View File

@@ -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";

View 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";

View File

@@ -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" && (