@pablozaiden/terminatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.devcontainer/devcontainer.json +19 -0
  2. package/.devcontainer/install-prerequisites.sh +49 -0
  3. package/.github/workflows/copilot-setup-steps.yml +32 -0
  4. package/.github/workflows/pull-request.yml +27 -0
  5. package/.github/workflows/release-npm-package.yml +78 -0
  6. package/LICENSE +21 -0
  7. package/README.md +524 -0
  8. package/examples/tui-app/commands/greet.ts +75 -0
  9. package/examples/tui-app/commands/index.ts +3 -0
  10. package/examples/tui-app/commands/math.ts +114 -0
  11. package/examples/tui-app/commands/status.ts +75 -0
  12. package/examples/tui-app/index.ts +34 -0
  13. package/guides/01-hello-world.md +96 -0
  14. package/guides/02-adding-options.md +103 -0
  15. package/guides/03-multiple-commands.md +163 -0
  16. package/guides/04-subcommands.md +206 -0
  17. package/guides/05-interactive-tui.md +194 -0
  18. package/guides/06-config-validation.md +264 -0
  19. package/guides/07-async-cancellation.md +388 -0
  20. package/guides/08-complete-application.md +673 -0
  21. package/guides/README.md +74 -0
  22. package/package.json +32 -0
  23. package/src/__tests__/application.test.ts +425 -0
  24. package/src/__tests__/buildCliCommand.test.ts +125 -0
  25. package/src/__tests__/builtins.test.ts +133 -0
  26. package/src/__tests__/colors.test.ts +127 -0
  27. package/src/__tests__/command.test.ts +157 -0
  28. package/src/__tests__/commandClass.test.ts +130 -0
  29. package/src/__tests__/context.test.ts +97 -0
  30. package/src/__tests__/help.test.ts +412 -0
  31. package/src/__tests__/parser.test.ts +268 -0
  32. package/src/__tests__/registry.test.ts +195 -0
  33. package/src/__tests__/registryNew.test.ts +160 -0
  34. package/src/__tests__/schemaToFields.test.ts +176 -0
  35. package/src/__tests__/table.test.ts +146 -0
  36. package/src/__tests__/tui.test.ts +26 -0
  37. package/src/builtins/help.ts +85 -0
  38. package/src/builtins/index.ts +4 -0
  39. package/src/builtins/settings.ts +106 -0
  40. package/src/builtins/version.ts +72 -0
  41. package/src/cli/help.ts +174 -0
  42. package/src/cli/index.ts +3 -0
  43. package/src/cli/output/colors.ts +74 -0
  44. package/src/cli/output/index.ts +2 -0
  45. package/src/cli/output/table.ts +141 -0
  46. package/src/cli/parser.ts +241 -0
  47. package/src/commands/help.ts +50 -0
  48. package/src/commands/index.ts +1 -0
  49. package/src/components/index.ts +147 -0
  50. package/src/core/application.ts +461 -0
  51. package/src/core/command.ts +269 -0
  52. package/src/core/context.ts +112 -0
  53. package/src/core/help.ts +214 -0
  54. package/src/core/index.ts +15 -0
  55. package/src/core/logger.ts +164 -0
  56. package/src/core/registry.ts +140 -0
  57. package/src/hooks/index.ts +131 -0
  58. package/src/index.ts +137 -0
  59. package/src/registry/commandRegistry.ts +77 -0
  60. package/src/registry/index.ts +1 -0
  61. package/src/tui/TuiApp.tsx +582 -0
  62. package/src/tui/TuiApplication.tsx +230 -0
  63. package/src/tui/app.ts +29 -0
  64. package/src/tui/components/ActionButton.tsx +36 -0
  65. package/src/tui/components/CliModal.tsx +81 -0
  66. package/src/tui/components/CommandSelector.tsx +159 -0
  67. package/src/tui/components/ConfigForm.tsx +148 -0
  68. package/src/tui/components/EditorModal.tsx +177 -0
  69. package/src/tui/components/FieldRow.tsx +30 -0
  70. package/src/tui/components/Header.tsx +31 -0
  71. package/src/tui/components/JsonHighlight.tsx +128 -0
  72. package/src/tui/components/LogsPanel.tsx +86 -0
  73. package/src/tui/components/ResultsPanel.tsx +93 -0
  74. package/src/tui/components/StatusBar.tsx +59 -0
  75. package/src/tui/components/index.ts +13 -0
  76. package/src/tui/components/types.ts +30 -0
  77. package/src/tui/context/KeyboardContext.tsx +118 -0
  78. package/src/tui/context/index.ts +7 -0
  79. package/src/tui/hooks/index.ts +35 -0
  80. package/src/tui/hooks/useClipboard.ts +66 -0
  81. package/src/tui/hooks/useCommandExecutor.ts +131 -0
  82. package/src/tui/hooks/useConfigState.ts +171 -0
  83. package/src/tui/hooks/useKeyboardHandler.ts +91 -0
  84. package/src/tui/hooks/useLogStream.ts +96 -0
  85. package/src/tui/hooks/useSpinner.ts +46 -0
  86. package/src/tui/index.ts +65 -0
  87. package/src/tui/theme.ts +21 -0
  88. package/src/tui/utils/buildCliCommand.ts +90 -0
  89. package/src/tui/utils/index.ts +13 -0
  90. package/src/tui/utils/parameterPersistence.ts +96 -0
  91. package/src/tui/utils/schemaToFields.ts +144 -0
  92. package/src/types/command.ts +103 -0
  93. package/src/types/execution.ts +11 -0
  94. package/src/types/index.ts +1 -0
  95. package/tsconfig.json +25 -0
@@ -0,0 +1,118 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useCallback,
5
+ useRef,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import type { KeyEvent } from "@opentui/core";
10
+
11
+ /**
12
+ * Priority levels for keyboard event handlers.
13
+ * Higher priority handlers are called first.
14
+ */
15
+ export enum KeyboardPriority {
16
+ /** Modal/overlay handlers - highest priority, intercept first */
17
+ Modal = 100,
18
+ /** Focused section handlers - handle section-specific keys */
19
+ Focused = 50,
20
+ /** Global handlers - app-wide shortcuts, lowest priority */
21
+ Global = 0,
22
+ }
23
+
24
+ /**
25
+ * Extended keyboard event with custom stop propagation.
26
+ * Use `stopPropagation()` to prevent lower-priority handlers from receiving the event.
27
+ * Use `key.preventDefault()` only when you want to also block OpenTUI primitives.
28
+ */
29
+ export interface KeyboardEvent {
30
+ /** The underlying OpenTUI KeyEvent */
31
+ key: KeyEvent;
32
+ /** Stop propagation to lower-priority handlers in our system */
33
+ stopPropagation: () => void;
34
+ /** Whether propagation was stopped */
35
+ stopped: boolean;
36
+ }
37
+
38
+ export type KeyboardHandler = (event: KeyboardEvent) => void;
39
+
40
+ interface RegisteredHandler {
41
+ id: string;
42
+ handler: KeyboardHandler;
43
+ priority: KeyboardPriority;
44
+ }
45
+
46
+ interface KeyboardContextValue {
47
+ register: (id: string, handler: KeyboardHandler, priority: KeyboardPriority) => void;
48
+ unregister: (id: string) => void;
49
+ }
50
+
51
+ const KeyboardContext = createContext<KeyboardContextValue | null>(null);
52
+
53
+ interface KeyboardProviderProps {
54
+ children: ReactNode;
55
+ }
56
+
57
+ /**
58
+ * Provider that coordinates all keyboard handlers via a single useKeyboard call.
59
+ * Handlers are invoked in descending priority order (highest first).
60
+ * Propagation stops when a handler calls `stopPropagation()`.
61
+ */
62
+ export function KeyboardProvider({ children }: KeyboardProviderProps) {
63
+ const handlersRef = useRef<RegisteredHandler[]>([]);
64
+
65
+ const register = useCallback(
66
+ (id: string, handler: KeyboardHandler, priority: KeyboardPriority) => {
67
+ // Remove existing handler with same id (if any)
68
+ handlersRef.current = handlersRef.current.filter((h) => h.id !== id);
69
+ // Add new handler
70
+ handlersRef.current.push({ id, handler, priority });
71
+ // Sort by priority descending (highest first)
72
+ handlersRef.current.sort((a, b) => b.priority - a.priority);
73
+ },
74
+ []
75
+ );
76
+
77
+ const unregister = useCallback((id: string) => {
78
+ handlersRef.current = handlersRef.current.filter((h) => h.id !== id);
79
+ }, []);
80
+
81
+ // Single useKeyboard call that dispatches to all registered handlers
82
+ useKeyboard((key: KeyEvent) => {
83
+ // Create our wrapper event with custom stop propagation
84
+ const event: KeyboardEvent = {
85
+ key,
86
+ stopped: false,
87
+ stopPropagation() {
88
+ this.stopped = true;
89
+ },
90
+ };
91
+
92
+ for (const { handler } of handlersRef.current) {
93
+ // Stop if our propagation was stopped or if preventDefault was called
94
+ if (event.stopped || key.defaultPrevented) {
95
+ break;
96
+ }
97
+ handler(event);
98
+ }
99
+ });
100
+
101
+ return (
102
+ <KeyboardContext.Provider value={{ register, unregister }}>
103
+ {children}
104
+ </KeyboardContext.Provider>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Access the keyboard context for handler registration.
110
+ * @throws Error if used outside of KeyboardProvider
111
+ */
112
+ export function useKeyboardContext(): KeyboardContextValue {
113
+ const context = useContext(KeyboardContext);
114
+ if (!context) {
115
+ throw new Error("useKeyboardContext must be used within a KeyboardProvider");
116
+ }
117
+ return context;
118
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ KeyboardProvider,
3
+ useKeyboardContext,
4
+ KeyboardPriority,
5
+ type KeyboardEvent,
6
+ type KeyboardHandler,
7
+ } from "./KeyboardContext.tsx";
@@ -0,0 +1,35 @@
1
+ export {
2
+ useKeyboardHandler,
3
+ KeyboardPriority,
4
+ type KeyboardEvent,
5
+ } from "./useKeyboardHandler.ts";
6
+
7
+ export {
8
+ useClipboard,
9
+ type UseClipboardResult,
10
+ } from "./useClipboard.ts";
11
+
12
+ export {
13
+ useSpinner,
14
+ type UseSpinnerResult,
15
+ } from "./useSpinner.ts";
16
+
17
+ export {
18
+ useConfigState,
19
+ type UseConfigStateOptions,
20
+ type UseConfigStateResult,
21
+ } from "./useConfigState.ts";
22
+
23
+ export {
24
+ useCommandExecutor,
25
+ type UseCommandExecutorResult,
26
+ } from "./useCommandExecutor.ts";
27
+
28
+ export {
29
+ useLogStream,
30
+ LogLevel,
31
+ type LogEntry,
32
+ type LogEvent,
33
+ type LogSource,
34
+ type UseLogStreamResult,
35
+ } from "./useLogStream.ts";
@@ -0,0 +1,66 @@
1
+ import { useCallback, useState } from "react";
2
+ import * as fs from "fs";
3
+
4
+ /**
5
+ * Copy text to clipboard using OSC 52 escape sequence.
6
+ * Write directly to /dev/tty to bypass any stdout interception.
7
+ */
8
+ function copyWithOsc52(text: string): boolean {
9
+ try {
10
+ // Strip ANSI codes if Bun is available, otherwise use as-is
11
+ const cleanText = typeof Bun !== "undefined"
12
+ ? Bun.stripANSI(text)
13
+ : text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
14
+ const base64 = Buffer.from(cleanText).toString("base64");
15
+ // OSC 52 sequence: ESC ] 52 ; c ; <base64> BEL
16
+ const osc52 = `\x1b]52;c;${base64}\x07`;
17
+
18
+ // Try to write directly to the TTY to bypass OpenTUI's stdout capture
19
+ try {
20
+ const fd = fs.openSync('/dev/tty', 'w');
21
+ fs.writeSync(fd, osc52);
22
+ fs.closeSync(fd);
23
+ } catch {
24
+ // Fallback to stdout if /dev/tty is not available
25
+ process.stdout.write(osc52);
26
+ }
27
+
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export interface UseClipboardResult {
35
+ /** Copy text to clipboard */
36
+ copy: (text: string) => boolean;
37
+ /** Last action message for status display */
38
+ lastAction: string;
39
+ /** Set the last action message */
40
+ setLastAction: (action: string) => void;
41
+ /** Copy and set a success message */
42
+ copyWithMessage: (text: string, label: string) => void;
43
+ }
44
+
45
+ /**
46
+ * Hook for clipboard operations using OSC 52.
47
+ * Works in most modern terminal emulators.
48
+ */
49
+ export function useClipboard(): UseClipboardResult {
50
+ const [lastAction, setLastAction] = useState("");
51
+
52
+ const copy = useCallback((text: string): boolean => {
53
+ return copyWithOsc52(text);
54
+ }, []);
55
+
56
+ const copyWithMessage = useCallback((text: string, label: string) => {
57
+ const success = copyWithOsc52(text);
58
+ if (success) {
59
+ setLastAction(`✓ ${label} copied to clipboard`);
60
+ // Clear message after 2 seconds
61
+ setTimeout(() => setLastAction(""), 2000);
62
+ }
63
+ }, []);
64
+
65
+ return { copy, lastAction, setLastAction, copyWithMessage };
66
+ }
@@ -0,0 +1,131 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import type { CommandResult } from "../../core/command.ts";
3
+ import { AbortError } from "../../core/command.ts";
4
+
5
+ /**
6
+ * Outcome of command execution.
7
+ */
8
+ export interface ExecutionOutcome<TResult = CommandResult> {
9
+ success: boolean;
10
+ result?: TResult;
11
+ error?: Error;
12
+ /** Whether the command was cancelled */
13
+ cancelled?: boolean;
14
+ }
15
+
16
+ export interface UseCommandExecutorResult<TResult = CommandResult> {
17
+ /** Whether the command is currently executing */
18
+ isExecuting: boolean;
19
+ /** The result from the last execution */
20
+ result: TResult | null;
21
+ /** Error from the last execution, if any */
22
+ error: Error | null;
23
+ /** Whether the last execution was cancelled */
24
+ wasCancelled: boolean;
25
+ /** Execute the command - returns outcome when complete */
26
+ execute: (...args: unknown[]) => Promise<ExecutionOutcome<TResult>>;
27
+ /** Cancel the currently running command */
28
+ cancel: () => void;
29
+ /** Reset the state */
30
+ reset: () => void;
31
+ }
32
+
33
+ /**
34
+ * Hook for executing commands with loading/error/result state and cancellation support.
35
+ *
36
+ * @param executeFn - The async function to execute. Receives an AbortSignal as the last argument.
37
+ * @returns Executor state and functions
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * const { isExecuting, result, error, execute, cancel } = useCommandExecutor(
42
+ * async (config, signal) => {
43
+ * return await runCommand(config, signal);
44
+ * }
45
+ * );
46
+ *
47
+ * const outcome = await execute(config);
48
+ * if (outcome.cancelled) { ... }
49
+ * if (outcome.success) { ... }
50
+ * ```
51
+ */
52
+ export function useCommandExecutor<TResult = CommandResult>(
53
+ executeFn: (...args: unknown[]) => Promise<TResult | void>
54
+ ): UseCommandExecutorResult<TResult> {
55
+ const [isExecuting, setIsExecuting] = useState(false);
56
+ const [result, setResult] = useState<TResult | null>(null);
57
+ const [error, setError] = useState<Error | null>(null);
58
+ const [wasCancelled, setWasCancelled] = useState(false);
59
+
60
+ // Keep track of the current AbortController
61
+ const abortControllerRef = useRef<AbortController | null>(null);
62
+
63
+ const execute = useCallback(async (...args: unknown[]): Promise<ExecutionOutcome<TResult>> => {
64
+ // Cancel any previous execution
65
+ if (abortControllerRef.current) {
66
+ abortControllerRef.current.abort();
67
+ }
68
+
69
+ // Create a new AbortController for this execution
70
+ const abortController = new AbortController();
71
+ abortControllerRef.current = abortController;
72
+
73
+ setIsExecuting(true);
74
+ setError(null);
75
+ setResult(null);
76
+ setWasCancelled(false);
77
+
78
+ try {
79
+ // Pass the signal as the last argument
80
+ const res = await executeFn(...args, abortController.signal);
81
+
82
+ // Check if we were aborted
83
+ if (abortController.signal.aborted) {
84
+ setWasCancelled(true);
85
+ return { success: false, cancelled: true };
86
+ }
87
+
88
+ if (res !== undefined) {
89
+ setResult(res);
90
+ return { success: true, result: res };
91
+ }
92
+ return { success: true };
93
+ } catch (e) {
94
+ // Check if this was a cancellation
95
+ if (abortController.signal.aborted || e instanceof AbortError || (e instanceof Error && e.name === "AbortError")) {
96
+ setWasCancelled(true);
97
+ return { success: false, cancelled: true };
98
+ }
99
+
100
+ const err = e instanceof Error ? e : new Error(String(e));
101
+ setError(err);
102
+ return { success: false, error: err };
103
+ } finally {
104
+ setIsExecuting(false);
105
+ if (abortControllerRef.current === abortController) {
106
+ abortControllerRef.current = null;
107
+ }
108
+ }
109
+ }, [executeFn]);
110
+
111
+ const cancel = useCallback(() => {
112
+ if (abortControllerRef.current) {
113
+ abortControllerRef.current.abort();
114
+ abortControllerRef.current = null;
115
+ }
116
+ }, []);
117
+
118
+ const reset = useCallback(() => {
119
+ // Cancel any running execution
120
+ if (abortControllerRef.current) {
121
+ abortControllerRef.current.abort();
122
+ abortControllerRef.current = null;
123
+ }
124
+ setIsExecuting(false);
125
+ setResult(null);
126
+ setError(null);
127
+ setWasCancelled(false);
128
+ }, []);
129
+
130
+ return { isExecuting, result, error, wasCancelled, execute, cancel, reset };
131
+ }
@@ -0,0 +1,171 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import type { OptionSchema, OptionValues } from "../../types/command.ts";
5
+
6
+ export interface UseConfigStateOptions<T extends OptionSchema> {
7
+ /** Path to persist config (e.g., ~/.myapp/config.json) */
8
+ persistPath?: string;
9
+ /** Whether to auto-save on changes */
10
+ autoSave?: boolean;
11
+ /** Callback when a value changes */
12
+ onChange?: (key: keyof OptionValues<T>, value: unknown, values: OptionValues<T>) => void;
13
+ }
14
+
15
+ export interface UseConfigStateResult<T extends OptionSchema> {
16
+ /** Current config values */
17
+ values: OptionValues<T>;
18
+ /** Update a single value */
19
+ updateValue: <K extends keyof OptionValues<T>>(key: K, value: OptionValues<T>[K]) => void;
20
+ /** Reset all values to defaults */
21
+ resetToDefaults: () => void;
22
+ /** Save current values to disk (if persistPath is set) */
23
+ save: () => void;
24
+ /** Whether the config has been modified from defaults */
25
+ isDirty: boolean;
26
+ }
27
+
28
+ /**
29
+ * Extract default values from an option schema.
30
+ */
31
+ function getDefaultsFromSchema<T extends OptionSchema>(schema: T): OptionValues<T> {
32
+ const defaults: Record<string, unknown> = {};
33
+
34
+ for (const [key, def] of Object.entries(schema)) {
35
+ if (def.default !== undefined) {
36
+ defaults[key] = def.default;
37
+ } else {
38
+ // Provide type-appropriate defaults
39
+ switch (def.type) {
40
+ case "string":
41
+ defaults[key] = def.enum?.[0] ?? "";
42
+ break;
43
+ case "number":
44
+ defaults[key] = def.min ?? 0;
45
+ break;
46
+ case "boolean":
47
+ defaults[key] = false;
48
+ break;
49
+ case "array":
50
+ defaults[key] = [];
51
+ break;
52
+ }
53
+ }
54
+ }
55
+
56
+ return defaults as OptionValues<T>;
57
+ }
58
+
59
+ /**
60
+ * Load config from disk.
61
+ */
62
+ function loadFromDisk<T extends OptionSchema>(
63
+ path: string,
64
+ schema: T
65
+ ): OptionValues<T> | null {
66
+ try {
67
+ if (existsSync(path)) {
68
+ const content = readFileSync(path, "utf-8");
69
+ const saved = JSON.parse(content) as Partial<OptionValues<T>>;
70
+ const defaults = getDefaultsFromSchema(schema);
71
+ return { ...defaults, ...saved };
72
+ }
73
+ } catch {
74
+ // Ignore errors, return null to use defaults
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Save config to disk.
81
+ */
82
+ function saveToDisk<T extends OptionSchema>(
83
+ path: string,
84
+ values: OptionValues<T>
85
+ ): boolean {
86
+ try {
87
+ const dir = dirname(path);
88
+ if (!existsSync(dir)) {
89
+ mkdirSync(dir, { recursive: true });
90
+ }
91
+ writeFileSync(path, JSON.stringify(values, null, 2), "utf-8");
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Hook for managing config state with optional persistence.
100
+ *
101
+ * @param schema - Option schema defining the config structure
102
+ * @param options - Configuration options
103
+ * @returns Config state and update functions
104
+ *
105
+ * @example
106
+ * ```tsx
107
+ * const { values, updateValue } = useConfigState(myOptions, {
108
+ * persistPath: join(homedir(), ".myapp/config.json"),
109
+ * autoSave: true,
110
+ * });
111
+ * ```
112
+ */
113
+ export function useConfigState<T extends OptionSchema>(
114
+ schema: T,
115
+ options: UseConfigStateOptions<T> = {}
116
+ ): UseConfigStateResult<T> {
117
+ const { persistPath, autoSave = true, onChange } = options;
118
+ const schemaRef = useRef(schema);
119
+
120
+ // Initialize state
121
+ const [values, setValues] = useState<OptionValues<T>>(() => {
122
+ if (persistPath) {
123
+ const loaded = loadFromDisk(persistPath, schema);
124
+ if (loaded) return loaded;
125
+ }
126
+ return getDefaultsFromSchema(schema);
127
+ });
128
+
129
+ const [isDirty, setIsDirty] = useState(false);
130
+
131
+ // Update a single value
132
+ const updateValue = useCallback(<K extends keyof OptionValues<T>>(
133
+ key: K,
134
+ value: OptionValues<T>[K]
135
+ ) => {
136
+ setValues((prev) => {
137
+ const updated = { ...prev, [key]: value };
138
+
139
+ // Call onChange callback
140
+ onChange?.(key, value, updated);
141
+
142
+ // Auto-save if enabled
143
+ if (autoSave && persistPath) {
144
+ saveToDisk(persistPath, updated);
145
+ }
146
+
147
+ return updated;
148
+ });
149
+ setIsDirty(true);
150
+ }, [autoSave, persistPath, onChange]);
151
+
152
+ // Reset to defaults
153
+ const resetToDefaults = useCallback(() => {
154
+ const defaults = getDefaultsFromSchema(schemaRef.current);
155
+ setValues(defaults);
156
+ setIsDirty(false);
157
+
158
+ if (autoSave && persistPath) {
159
+ saveToDisk(persistPath, defaults);
160
+ }
161
+ }, [autoSave, persistPath]);
162
+
163
+ // Manual save
164
+ const save = useCallback(() => {
165
+ if (persistPath) {
166
+ saveToDisk(persistPath, values);
167
+ }
168
+ }, [persistPath, values]);
169
+
170
+ return { values, updateValue, resetToDefaults, save, isDirty };
171
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useId, useRef } from "react";
2
+ import {
3
+ useKeyboardContext,
4
+ KeyboardPriority,
5
+ type KeyboardHandler,
6
+ type KeyboardEvent,
7
+ } from "../context/KeyboardContext.tsx";
8
+
9
+ interface UseKeyboardHandlerOptions {
10
+ /**
11
+ * Whether the handler is currently enabled.
12
+ * When false, the handler is unregistered.
13
+ * Useful for conditionally handling keys only when focused.
14
+ * @default true
15
+ */
16
+ enabled?: boolean;
17
+
18
+ /**
19
+ * When true, automatically calls stopPropagation() after the handler runs,
20
+ * blocking keys from reaching lower-priority handlers.
21
+ * Does NOT block OpenTUI primitives (input/select) from receiving keys.
22
+ * Use this for modal dialogs that should capture keyboard focus.
23
+ * @default false
24
+ */
25
+ modal?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Register a keyboard handler with the KeyboardProvider.
30
+ *
31
+ * @param handler - Callback invoked on keyboard events.
32
+ * - Call `event.stopPropagation()` to stop our handlers from receiving the event.
33
+ * - Call `event.key.preventDefault()` to also block OpenTUI primitives.
34
+ * @param priority - Handler priority level. Higher priorities are called first.
35
+ * @param options - Optional configuration (e.g., enabled flag, modal behavior).
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * // Modal handler - blocks lower-priority handlers but lets OpenTUI primitives work
40
+ * useKeyboardHandler(
41
+ * (event) => {
42
+ * if (event.key.name === "escape") {
43
+ * onClose();
44
+ * }
45
+ * // Other keys pass through to <input>/<select> but not to ConfigForm
46
+ * },
47
+ * KeyboardPriority.Modal,
48
+ * { enabled: isVisible, modal: true }
49
+ * );
50
+ * ```
51
+ */
52
+ export function useKeyboardHandler(
53
+ handler: KeyboardHandler,
54
+ priority: KeyboardPriority,
55
+ options: UseKeyboardHandlerOptions = {}
56
+ ): void {
57
+ const { enabled = true, modal = false } = options;
58
+ const { register, unregister } = useKeyboardContext();
59
+ const id = useId();
60
+
61
+ // Keep handler ref stable to avoid re-registrations on every render
62
+ const handlerRef = useRef(handler);
63
+ handlerRef.current = handler;
64
+
65
+ // Keep modal ref stable
66
+ const modalRef = useRef(modal);
67
+ modalRef.current = modal;
68
+
69
+ useEffect(() => {
70
+ if (!enabled) {
71
+ unregister(id);
72
+ return;
73
+ }
74
+
75
+ // Register with a stable wrapper that calls the current handler
76
+ register(id, (event: KeyboardEvent) => {
77
+ handlerRef.current(event);
78
+ // For modals, always stop propagation to our handlers (but not OpenTUI primitives)
79
+ if (modalRef.current) {
80
+ event.stopPropagation();
81
+ }
82
+ }, priority);
83
+
84
+ return () => {
85
+ unregister(id);
86
+ };
87
+ }, [id, priority, enabled, register, unregister]);
88
+ }
89
+
90
+ export { KeyboardPriority };
91
+ export type { KeyboardEvent };