@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.
- package/.devcontainer/devcontainer.json +19 -0
- package/.devcontainer/install-prerequisites.sh +49 -0
- package/.github/workflows/copilot-setup-steps.yml +32 -0
- package/.github/workflows/pull-request.yml +27 -0
- package/.github/workflows/release-npm-package.yml +78 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/examples/tui-app/commands/greet.ts +75 -0
- package/examples/tui-app/commands/index.ts +3 -0
- package/examples/tui-app/commands/math.ts +114 -0
- package/examples/tui-app/commands/status.ts +75 -0
- package/examples/tui-app/index.ts +34 -0
- package/guides/01-hello-world.md +96 -0
- package/guides/02-adding-options.md +103 -0
- package/guides/03-multiple-commands.md +163 -0
- package/guides/04-subcommands.md +206 -0
- package/guides/05-interactive-tui.md +194 -0
- package/guides/06-config-validation.md +264 -0
- package/guides/07-async-cancellation.md +388 -0
- package/guides/08-complete-application.md +673 -0
- package/guides/README.md +74 -0
- package/package.json +32 -0
- package/src/__tests__/application.test.ts +425 -0
- package/src/__tests__/buildCliCommand.test.ts +125 -0
- package/src/__tests__/builtins.test.ts +133 -0
- package/src/__tests__/colors.test.ts +127 -0
- package/src/__tests__/command.test.ts +157 -0
- package/src/__tests__/commandClass.test.ts +130 -0
- package/src/__tests__/context.test.ts +97 -0
- package/src/__tests__/help.test.ts +412 -0
- package/src/__tests__/parser.test.ts +268 -0
- package/src/__tests__/registry.test.ts +195 -0
- package/src/__tests__/registryNew.test.ts +160 -0
- package/src/__tests__/schemaToFields.test.ts +176 -0
- package/src/__tests__/table.test.ts +146 -0
- package/src/__tests__/tui.test.ts +26 -0
- package/src/builtins/help.ts +85 -0
- package/src/builtins/index.ts +4 -0
- package/src/builtins/settings.ts +106 -0
- package/src/builtins/version.ts +72 -0
- package/src/cli/help.ts +174 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/output/colors.ts +74 -0
- package/src/cli/output/index.ts +2 -0
- package/src/cli/output/table.ts +141 -0
- package/src/cli/parser.ts +241 -0
- package/src/commands/help.ts +50 -0
- package/src/commands/index.ts +1 -0
- package/src/components/index.ts +147 -0
- package/src/core/application.ts +461 -0
- package/src/core/command.ts +269 -0
- package/src/core/context.ts +112 -0
- package/src/core/help.ts +214 -0
- package/src/core/index.ts +15 -0
- package/src/core/logger.ts +164 -0
- package/src/core/registry.ts +140 -0
- package/src/hooks/index.ts +131 -0
- package/src/index.ts +137 -0
- package/src/registry/commandRegistry.ts +77 -0
- package/src/registry/index.ts +1 -0
- package/src/tui/TuiApp.tsx +582 -0
- package/src/tui/TuiApplication.tsx +230 -0
- package/src/tui/app.ts +29 -0
- package/src/tui/components/ActionButton.tsx +36 -0
- package/src/tui/components/CliModal.tsx +81 -0
- package/src/tui/components/CommandSelector.tsx +159 -0
- package/src/tui/components/ConfigForm.tsx +148 -0
- package/src/tui/components/EditorModal.tsx +177 -0
- package/src/tui/components/FieldRow.tsx +30 -0
- package/src/tui/components/Header.tsx +31 -0
- package/src/tui/components/JsonHighlight.tsx +128 -0
- package/src/tui/components/LogsPanel.tsx +86 -0
- package/src/tui/components/ResultsPanel.tsx +93 -0
- package/src/tui/components/StatusBar.tsx +59 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/components/types.ts +30 -0
- package/src/tui/context/KeyboardContext.tsx +118 -0
- package/src/tui/context/index.ts +7 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useClipboard.ts +66 -0
- package/src/tui/hooks/useCommandExecutor.ts +131 -0
- package/src/tui/hooks/useConfigState.ts +171 -0
- package/src/tui/hooks/useKeyboardHandler.ts +91 -0
- package/src/tui/hooks/useLogStream.ts +96 -0
- package/src/tui/hooks/useSpinner.ts +46 -0
- package/src/tui/index.ts +65 -0
- package/src/tui/theme.ts +21 -0
- package/src/tui/utils/buildCliCommand.ts +90 -0
- package/src/tui/utils/index.ts +13 -0
- package/src/tui/utils/parameterPersistence.ts +96 -0
- package/src/tui/utils/schemaToFields.ts +144 -0
- package/src/types/command.ts +103 -0
- package/src/types/execution.ts +11 -0
- package/src/types/index.ts +1 -0
- 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,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 };
|