@pablozaiden/terminatui 0.2.0 → 0.3.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/README.md +64 -43
- package/package.json +11 -8
- package/src/__tests__/application.test.ts +87 -68
- package/src/__tests__/buildCliCommand.test.ts +99 -119
- package/src/__tests__/builtins.test.ts +27 -75
- package/src/__tests__/command.test.ts +100 -131
- package/src/__tests__/configOnChange.test.ts +63 -0
- package/src/__tests__/context.test.ts +1 -26
- package/src/__tests__/helpCore.test.ts +227 -0
- package/src/__tests__/parser.test.ts +98 -244
- package/src/__tests__/registry.test.ts +33 -160
- package/src/__tests__/schemaToFields.test.ts +75 -158
- package/src/builtins/help.ts +12 -4
- package/src/builtins/settings.ts +18 -32
- package/src/builtins/version.ts +3 -3
- package/src/cli/output/colors.ts +1 -1
- package/src/cli/parser.ts +26 -95
- package/src/core/application.ts +192 -110
- package/src/core/command.ts +26 -9
- package/src/core/context.ts +31 -20
- package/src/core/help.ts +24 -18
- package/src/core/knownCommands.ts +13 -0
- package/src/core/logger.ts +39 -42
- package/src/core/registry.ts +5 -12
- package/src/index.ts +22 -137
- package/src/tui/TuiApplication.tsx +63 -120
- package/src/tui/TuiRoot.tsx +135 -0
- package/src/tui/adapters/factory.ts +19 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +139 -0
- package/src/tui/adapters/ink/components/Button.tsx +12 -0
- package/src/tui/adapters/ink/components/Code.tsx +6 -0
- package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
- package/src/tui/adapters/ink/components/Container.tsx +5 -0
- package/src/tui/adapters/ink/components/Field.tsx +12 -0
- package/src/tui/adapters/ink/components/Label.tsx +24 -0
- package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
- package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
- package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
- package/src/tui/adapters/ink/components/Panel.tsx +15 -0
- package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
- package/src/tui/adapters/ink/components/Select.tsx +44 -0
- package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
- package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
- package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
- package/src/tui/adapters/ink/components/Value.tsx +7 -0
- package/src/tui/adapters/ink/keyboard.ts +97 -0
- package/src/tui/adapters/ink/utils.ts +16 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +119 -0
- package/src/tui/adapters/opentui/components/Button.tsx +13 -0
- package/src/tui/adapters/opentui/components/Code.tsx +12 -0
- package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
- package/src/tui/adapters/opentui/components/Container.tsx +56 -0
- package/src/tui/adapters/opentui/components/Field.tsx +18 -0
- package/src/tui/adapters/opentui/components/Label.tsx +15 -0
- package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
- package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
- package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
- package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
- package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
- package/src/tui/adapters/opentui/components/Select.tsx +59 -0
- package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
- package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
- package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
- package/src/tui/adapters/opentui/components/Value.tsx +13 -0
- package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
- package/src/tui/adapters/opentui/keyboard.ts +61 -0
- package/src/tui/adapters/types.ts +71 -0
- package/src/tui/components/ActionButton.tsx +0 -36
- package/src/tui/components/CommandSelector.tsx +45 -92
- package/src/tui/components/ConfigForm.tsx +68 -42
- package/src/tui/components/FieldRow.tsx +0 -30
- package/src/tui/components/Header.tsx +14 -13
- package/src/tui/components/JsonHighlight.tsx +10 -17
- package/src/tui/components/ModalBase.tsx +38 -0
- package/src/tui/components/ResultsPanel.tsx +27 -36
- package/src/tui/components/StatusBar.tsx +24 -39
- package/src/tui/components/logColors.ts +12 -0
- package/src/tui/context/ClipboardContext.tsx +87 -0
- package/src/tui/context/ExecutorContext.tsx +139 -0
- package/src/tui/context/KeyboardContext.tsx +85 -71
- package/src/tui/context/LogsContext.tsx +35 -0
- package/src/tui/context/NavigationContext.tsx +194 -0
- package/src/tui/context/RendererContext.tsx +20 -0
- package/src/tui/context/TuiAppContext.tsx +58 -0
- package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
- package/src/tui/hooks/useBackHandler.ts +34 -0
- package/src/tui/hooks/useClipboard.ts +40 -25
- package/src/tui/hooks/useClipboardProvider.ts +42 -0
- package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
- package/src/tui/modals/CliModal.tsx +82 -0
- package/src/tui/modals/EditorModal.tsx +207 -0
- package/src/tui/modals/LogsModal.tsx +98 -0
- package/src/tui/registry.ts +102 -0
- package/src/tui/screens/CommandSelectScreen.tsx +162 -0
- package/src/tui/screens/ConfigScreen.tsx +165 -0
- package/src/tui/screens/ErrorScreen.tsx +58 -0
- package/src/tui/screens/ResultsScreen.tsx +68 -0
- package/src/tui/screens/RunningScreen.tsx +72 -0
- package/src/tui/screens/ScreenBase.ts +6 -0
- package/src/tui/semantic/Button.tsx +7 -0
- package/src/tui/semantic/Code.tsx +7 -0
- package/src/tui/semantic/CodeHighlight.tsx +7 -0
- package/src/tui/semantic/Container.tsx +7 -0
- package/src/tui/semantic/Field.tsx +7 -0
- package/src/tui/semantic/Label.tsx +7 -0
- package/src/tui/semantic/MenuButton.tsx +7 -0
- package/src/tui/semantic/MenuItem.tsx +7 -0
- package/src/tui/semantic/Overlay.tsx +7 -0
- package/src/tui/semantic/Panel.tsx +7 -0
- package/src/tui/semantic/ScrollView.tsx +9 -0
- package/src/tui/semantic/Select.tsx +7 -0
- package/src/tui/semantic/Spacer.tsx +7 -0
- package/src/tui/semantic/Spinner.tsx +7 -0
- package/src/tui/semantic/TextInput.tsx +7 -0
- package/src/tui/semantic/Value.tsx +7 -0
- package/src/tui/semantic/types.ts +195 -0
- package/src/tui/theme.ts +25 -14
- package/src/tui/utils/buildCliCommand.ts +1 -0
- package/src/tui/utils/getEnumKeys.ts +3 -0
- package/src/tui/utils/parameterPersistence.ts +1 -0
- package/src/types/command.ts +0 -60
- package/.devcontainer/devcontainer.json +0 -19
- package/.devcontainer/install-prerequisites.sh +0 -49
- package/.github/workflows/copilot-setup-steps.yml +0 -32
- package/.github/workflows/pull-request.yml +0 -27
- package/.github/workflows/release-npm-package.yml +0 -81
- package/AGENTS.md +0 -31
- package/bun.lock +0 -236
- package/examples/tui-app/commands/config/app/get.ts +0 -66
- package/examples/tui-app/commands/config/app/index.ts +0 -27
- package/examples/tui-app/commands/config/app/set.ts +0 -86
- package/examples/tui-app/commands/config/index.ts +0 -32
- package/examples/tui-app/commands/config/user/get.ts +0 -65
- package/examples/tui-app/commands/config/user/index.ts +0 -27
- package/examples/tui-app/commands/config/user/set.ts +0 -61
- package/examples/tui-app/commands/greet.ts +0 -76
- package/examples/tui-app/commands/index.ts +0 -4
- package/examples/tui-app/commands/math.ts +0 -115
- package/examples/tui-app/commands/status.ts +0 -77
- package/examples/tui-app/index.ts +0 -35
- package/guides/01-hello-world.md +0 -96
- package/guides/02-adding-options.md +0 -103
- package/guides/03-multiple-commands.md +0 -163
- package/guides/04-subcommands.md +0 -206
- package/guides/05-interactive-tui.md +0 -194
- package/guides/06-config-validation.md +0 -264
- package/guides/07-async-cancellation.md +0 -336
- package/guides/08-complete-application.md +0 -537
- package/guides/README.md +0 -74
- package/src/__tests__/colors.test.ts +0 -127
- package/src/__tests__/commandClass.test.ts +0 -130
- package/src/__tests__/help.test.ts +0 -412
- package/src/__tests__/registryNew.test.ts +0 -160
- package/src/__tests__/table.test.ts +0 -146
- package/src/__tests__/tui.test.ts +0 -26
- package/src/builtins/index.ts +0 -4
- package/src/cli/help.ts +0 -174
- package/src/cli/index.ts +0 -3
- package/src/cli/output/index.ts +0 -2
- package/src/cli/output/table.ts +0 -141
- package/src/commands/help.ts +0 -50
- package/src/commands/index.ts +0 -1
- package/src/components/index.ts +0 -147
- package/src/core/index.ts +0 -15
- package/src/hooks/index.ts +0 -131
- package/src/registry/commandRegistry.ts +0 -77
- package/src/registry/index.ts +0 -1
- package/src/tui/TuiApp.tsx +0 -619
- package/src/tui/app.ts +0 -29
- package/src/tui/components/CliModal.tsx +0 -81
- package/src/tui/components/EditorModal.tsx +0 -177
- package/src/tui/components/LogsPanel.tsx +0 -86
- package/src/tui/components/index.ts +0 -13
- package/src/tui/context/index.ts +0 -7
- package/src/tui/hooks/index.ts +0 -35
- package/src/tui/hooks/useKeyboardHandler.ts +0 -91
- package/src/tui/hooks/useLogStream.ts +0 -96
- package/src/tui/index.ts +0 -65
- package/src/tui/utils/index.ts +0 -13
- package/src/types/index.ts +0 -1
- package/tsconfig.json +0 -25
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useState,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { AnyCommand, CommandResult } from "../../core/command.ts";
|
|
10
|
+
import type { OptionSchema, OptionValues } from "../../types/command.ts";
|
|
11
|
+
import { AbortError } from "../../core/command.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Outcome of command execution.
|
|
15
|
+
*/
|
|
16
|
+
export interface ExecutionOutcome {
|
|
17
|
+
success: boolean;
|
|
18
|
+
result?: CommandResult;
|
|
19
|
+
error?: Error;
|
|
20
|
+
cancelled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Executor context value - provides command execution capabilities to screens.
|
|
25
|
+
*/
|
|
26
|
+
export interface ExecutorContextValue {
|
|
27
|
+
/** Whether a command is currently executing */
|
|
28
|
+
isExecuting: boolean;
|
|
29
|
+
/** Execute a command with the given values */
|
|
30
|
+
execute: (command: AnyCommand, values: Record<string, unknown>) => Promise<ExecutionOutcome>;
|
|
31
|
+
/** Cancel the currently executing command */
|
|
32
|
+
cancel: () => void;
|
|
33
|
+
/** Reset executor state */
|
|
34
|
+
reset: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ExecutorContext = createContext<ExecutorContextValue | null>(null);
|
|
38
|
+
|
|
39
|
+
interface ExecutorProviderProps {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Provider that gives screens access to command execution capabilities.
|
|
45
|
+
* Screens can execute commands, check execution state, and cancel execution.
|
|
46
|
+
*/
|
|
47
|
+
export function ExecutorProvider({ children }: ExecutorProviderProps) {
|
|
48
|
+
const [isExecuting, setIsExecuting] = useState(false);
|
|
49
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
50
|
+
|
|
51
|
+
const execute = useCallback(async (
|
|
52
|
+
command: AnyCommand,
|
|
53
|
+
values: Record<string, unknown>
|
|
54
|
+
): Promise<ExecutionOutcome> => {
|
|
55
|
+
// Cancel any previous execution
|
|
56
|
+
if (abortControllerRef.current) {
|
|
57
|
+
abortControllerRef.current.abort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const abortController = new AbortController();
|
|
61
|
+
abortControllerRef.current = abortController;
|
|
62
|
+
|
|
63
|
+
setIsExecuting(true);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// Build config if command supports it
|
|
67
|
+
let configOrValues: unknown = values;
|
|
68
|
+
if (command.buildConfig) {
|
|
69
|
+
configOrValues = await command.buildConfig(values as OptionValues<OptionSchema>);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Execute the command
|
|
73
|
+
const result = await command.execute(
|
|
74
|
+
configOrValues as OptionValues<OptionSchema>,
|
|
75
|
+
{ signal: abortController.signal }
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Check if aborted during execution
|
|
79
|
+
if (abortController.signal.aborted) {
|
|
80
|
+
return { success: false, cancelled: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
success: true,
|
|
85
|
+
result: result as CommandResult | undefined,
|
|
86
|
+
};
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// Check for cancellation
|
|
89
|
+
if (
|
|
90
|
+
abortController.signal.aborted ||
|
|
91
|
+
e instanceof AbortError ||
|
|
92
|
+
(e instanceof Error && e.name === "AbortError")
|
|
93
|
+
) {
|
|
94
|
+
return { success: false, cancelled: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
98
|
+
return { success: false, error };
|
|
99
|
+
} finally {
|
|
100
|
+
setIsExecuting(false);
|
|
101
|
+
if (abortControllerRef.current === abortController) {
|
|
102
|
+
abortControllerRef.current = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const cancel = useCallback(() => {
|
|
108
|
+
if (abortControllerRef.current) {
|
|
109
|
+
abortControllerRef.current.abort();
|
|
110
|
+
abortControllerRef.current = null;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const reset = useCallback(() => {
|
|
115
|
+
if (abortControllerRef.current) {
|
|
116
|
+
abortControllerRef.current.abort();
|
|
117
|
+
abortControllerRef.current = null;
|
|
118
|
+
}
|
|
119
|
+
setIsExecuting(false);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<ExecutorContext.Provider value={{ isExecuting, execute, cancel, reset }}>
|
|
124
|
+
{children}
|
|
125
|
+
</ExecutorContext.Provider>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Access the executor context.
|
|
131
|
+
* @throws Error if used outside of ExecutorProvider
|
|
132
|
+
*/
|
|
133
|
+
export function useExecutor(): ExecutorContextValue {
|
|
134
|
+
const context = useContext(ExecutorContext);
|
|
135
|
+
if (!context) {
|
|
136
|
+
throw new Error("useExecutor must be used within an ExecutorProvider");
|
|
137
|
+
}
|
|
138
|
+
return context;
|
|
139
|
+
}
|
|
@@ -2,50 +2,43 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
4
|
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
5
7
|
useRef,
|
|
6
8
|
type ReactNode,
|
|
7
9
|
} from "react";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
+
import type { KeyboardEvent, KeyHandler } from "../adapters/types.ts";
|
|
11
|
+
import { useRenderer } from "./RendererContext.tsx";
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
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,
|
|
13
|
+
function useRendererKeyboard() {
|
|
14
|
+
return useRenderer().keyboard;
|
|
22
15
|
}
|
|
23
16
|
|
|
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
17
|
|
|
40
|
-
|
|
41
|
-
id: string;
|
|
42
|
-
handler: KeyboardHandler;
|
|
43
|
-
priority: KeyboardPriority;
|
|
44
|
-
}
|
|
18
|
+
export type GlobalKeyHandler = (event: KeyboardEvent) => boolean;
|
|
45
19
|
|
|
46
20
|
interface KeyboardContextValue {
|
|
47
|
-
|
|
48
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Set the active handler (only one at a time - the topmost screen/modal).
|
|
23
|
+
* Returns unregister function.
|
|
24
|
+
*/
|
|
25
|
+
setActiveHandler: (id: string, handler: KeyHandler) => () => void;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set the global handler (processed before active handler).
|
|
29
|
+
* Only one global handler is supported.
|
|
30
|
+
* Returns unregister function.
|
|
31
|
+
*/
|
|
32
|
+
setGlobalHandler: (handler: GlobalKeyHandler) => () => void;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Temporarily capture input (e.g. while an Ink TextInput is focused).
|
|
36
|
+
*
|
|
37
|
+
* While captured, only the global handler can receive events; the active handler
|
|
38
|
+
* stack is skipped. This prevents screens from doing extra work on every
|
|
39
|
+
* character typed.
|
|
40
|
+
*/
|
|
41
|
+
setInputCaptured: (captured: boolean) => void;
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
|
|
@@ -55,58 +48,79 @@ interface KeyboardProviderProps {
|
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
/**
|
|
58
|
-
* Provider that coordinates
|
|
59
|
-
*
|
|
60
|
-
*
|
|
51
|
+
* Provider that coordinates keyboard handling with a simple model:
|
|
52
|
+
* 1. Global handler processes keys first (for app-wide shortcuts like Ctrl+L, Ctrl+Y, Esc)
|
|
53
|
+
* 2. If not handled, the active handler (topmost screen/modal) gets the key
|
|
54
|
+
*
|
|
55
|
+
* Only ONE active handler is registered at a time - when a modal opens, it becomes
|
|
56
|
+
* the active handler; when it closes, the previous handler is restored.
|
|
61
57
|
*/
|
|
62
58
|
export function KeyboardProvider({ children }: KeyboardProviderProps) {
|
|
63
|
-
const
|
|
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
|
-
);
|
|
59
|
+
const keyboard = useRendererKeyboard();
|
|
76
60
|
|
|
77
|
-
const
|
|
78
|
-
|
|
61
|
+
const handlerStackRef = useRef<{ id: string; handler: KeyHandler }[]>([]);
|
|
62
|
+
const globalHandlerRef = useRef<GlobalKeyHandler | null>(null);
|
|
63
|
+
const inputCapturedRef = useRef(false);
|
|
64
|
+
|
|
65
|
+
const setActiveHandler = useCallback((id: string, handler: KeyHandler) => {
|
|
66
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
67
|
+
handlerStackRef.current.push({ id, handler });
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
handlerStackRef.current = handlerStackRef.current.filter((h) => h.id !== id);
|
|
71
|
+
};
|
|
79
72
|
}, []);
|
|
80
73
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
stopPropagation() {
|
|
88
|
-
this.stopped = true;
|
|
89
|
-
},
|
|
74
|
+
const setGlobalHandler = useCallback((handler: GlobalKeyHandler) => {
|
|
75
|
+
const previous = globalHandlerRef.current;
|
|
76
|
+
globalHandlerRef.current = handler;
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
globalHandlerRef.current = previous;
|
|
90
80
|
};
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const unregister = keyboard.setGlobalHandler((event: KeyboardEvent) => {
|
|
85
|
+
if (globalHandlerRef.current?.(event)) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
91
88
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (event.stopped || key.defaultPrevented) {
|
|
95
|
-
break;
|
|
89
|
+
if (inputCapturedRef.current) {
|
|
90
|
+
return false;
|
|
96
91
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
|
|
93
|
+
const activeHandler = handlerStackRef.current[handlerStackRef.current.length - 1];
|
|
94
|
+
if (activeHandler) {
|
|
95
|
+
return activeHandler.handler(event);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
unregister();
|
|
103
|
+
};
|
|
104
|
+
}, [keyboard]);
|
|
105
|
+
|
|
106
|
+
const setInputCaptured = useCallback((captured: boolean) => {
|
|
107
|
+
inputCapturedRef.current = captured;
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const value = useMemo<KeyboardContextValue>(
|
|
111
|
+
() => ({ setActiveHandler, setGlobalHandler, setInputCaptured }),
|
|
112
|
+
[setActiveHandler, setGlobalHandler, setInputCaptured]
|
|
113
|
+
);
|
|
100
114
|
|
|
101
115
|
return (
|
|
102
|
-
<KeyboardContext.Provider value={
|
|
116
|
+
<KeyboardContext.Provider value={value}>
|
|
103
117
|
{children}
|
|
104
118
|
</KeyboardContext.Provider>
|
|
105
119
|
);
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
/**
|
|
109
|
-
* Access the keyboard context
|
|
123
|
+
* Access the keyboard context.
|
|
110
124
|
* @throws Error if used outside of KeyboardProvider
|
|
111
125
|
*/
|
|
112
126
|
export function useKeyboardContext(): KeyboardContextValue {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { AppContext } from "../../core/context.ts";
|
|
3
|
+
import type { LogEvent } from "../../core/logger.ts";
|
|
4
|
+
|
|
5
|
+
interface LogsContextValue {
|
|
6
|
+
logs: LogEvent[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LogsContext = createContext<LogsContextValue | null>(null);
|
|
10
|
+
|
|
11
|
+
export function LogsProvider({ children }: { children: ReactNode }) {
|
|
12
|
+
const [logs, setLogs] = useState<LogEvent[]>([]);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const unsubscribe = AppContext.current.logger.onLogEvent((event: LogEvent) => {
|
|
16
|
+
setLogs((prev) => [...prev, event]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return () => {
|
|
20
|
+
unsubscribe?.();
|
|
21
|
+
};
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const value = useMemo(() => ({ logs }), [logs]);
|
|
25
|
+
|
|
26
|
+
return <LogsContext.Provider value={value}>{children}</LogsContext.Provider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useLogs(): LogsContextValue {
|
|
30
|
+
const context = useContext(LogsContext);
|
|
31
|
+
if (!context) {
|
|
32
|
+
throw new Error("useLogs must be used within LogsProvider");
|
|
33
|
+
}
|
|
34
|
+
return context;
|
|
35
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useMemo,
|
|
5
|
+
useReducer,
|
|
6
|
+
useRef,
|
|
7
|
+
useCallback,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
export interface ScreenEntry<TParams = unknown> {
|
|
12
|
+
route: string;
|
|
13
|
+
params?: TParams;
|
|
14
|
+
meta?: { focus?: string; breadcrumb?: string[] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ModalEntry<TParams = unknown> {
|
|
18
|
+
id: string;
|
|
19
|
+
params?: TParams;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Back handler function.
|
|
24
|
+
* Return true if handled, false to let navigation handle it.
|
|
25
|
+
*/
|
|
26
|
+
export type BackHandler = () => boolean;
|
|
27
|
+
|
|
28
|
+
export interface NavigationAPI {
|
|
29
|
+
current: ScreenEntry;
|
|
30
|
+
stack: ScreenEntry[];
|
|
31
|
+
push: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
|
|
32
|
+
replace: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
|
|
33
|
+
reset: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
|
|
34
|
+
pop: () => void;
|
|
35
|
+
canGoBack: boolean;
|
|
36
|
+
|
|
37
|
+
modalStack: ModalEntry[];
|
|
38
|
+
currentModal?: ModalEntry;
|
|
39
|
+
openModal: <TParams>(id: string, params?: TParams) => void;
|
|
40
|
+
closeModal: () => void;
|
|
41
|
+
hasModal: boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle back/escape.
|
|
45
|
+
* 1. If modal is open, closes modal
|
|
46
|
+
* 2. Otherwise, calls the registered back handler (if any)
|
|
47
|
+
* 3. If no handler or handler returns false, pops the stack
|
|
48
|
+
*/
|
|
49
|
+
goBack: () => void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register a back handler for the current screen.
|
|
53
|
+
* The handler is called when goBack() is invoked (after closing modals).
|
|
54
|
+
* Return true from the handler if it handled the back action.
|
|
55
|
+
*/
|
|
56
|
+
setBackHandler: (handler: BackHandler | null) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type NavigationProviderProps<TParams = unknown> = {
|
|
60
|
+
initialScreen: ScreenEntry<TParams>;
|
|
61
|
+
children: ReactNode;
|
|
62
|
+
/** Called when we can't go back anymore (at root with empty stack) */
|
|
63
|
+
onExit?: () => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type NavigationAction =
|
|
67
|
+
| { type: "push"; screen: ScreenEntry }
|
|
68
|
+
| { type: "replace"; screen: ScreenEntry }
|
|
69
|
+
| { type: "reset"; screen: ScreenEntry }
|
|
70
|
+
| { type: "pop" }
|
|
71
|
+
| { type: "openModal"; modal: ModalEntry }
|
|
72
|
+
| { type: "closeModal" };
|
|
73
|
+
|
|
74
|
+
type NavigationState = {
|
|
75
|
+
stack: ScreenEntry[];
|
|
76
|
+
modalStack: ModalEntry[];
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function navigationReducer(
|
|
80
|
+
state: NavigationState,
|
|
81
|
+
action: NavigationAction
|
|
82
|
+
): NavigationState {
|
|
83
|
+
switch (action.type) {
|
|
84
|
+
case "push":
|
|
85
|
+
return { ...state, stack: [...state.stack, action.screen] };
|
|
86
|
+
case "replace": {
|
|
87
|
+
const nextStack = state.stack.length === 0
|
|
88
|
+
? [action.screen]
|
|
89
|
+
: [...state.stack.slice(0, -1), action.screen];
|
|
90
|
+
return { ...state, stack: nextStack };
|
|
91
|
+
}
|
|
92
|
+
case "reset":
|
|
93
|
+
return { ...state, stack: [action.screen] };
|
|
94
|
+
case "pop": {
|
|
95
|
+
if (state.stack.length <= 1) return state;
|
|
96
|
+
return { ...state, stack: state.stack.slice(0, -1) };
|
|
97
|
+
}
|
|
98
|
+
case "openModal":
|
|
99
|
+
return { ...state, modalStack: [...state.modalStack, action.modal] };
|
|
100
|
+
case "closeModal": {
|
|
101
|
+
if (state.modalStack.length === 0) return state;
|
|
102
|
+
return { ...state, modalStack: state.modalStack.slice(0, -1) };
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return state;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const NavigationContext = createContext<NavigationAPI | null>(null);
|
|
110
|
+
|
|
111
|
+
export function NavigationProvider<TParams = unknown>({
|
|
112
|
+
initialScreen,
|
|
113
|
+
children,
|
|
114
|
+
onExit,
|
|
115
|
+
}: NavigationProviderProps<TParams>) {
|
|
116
|
+
const [state, dispatch] = useReducer(navigationReducer, {
|
|
117
|
+
stack: [initialScreen],
|
|
118
|
+
modalStack: [],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Back handler ref - set by the current screen
|
|
122
|
+
const backHandlerRef = useRef<BackHandler | null>(null);
|
|
123
|
+
|
|
124
|
+
const setBackHandler = useCallback((handler: BackHandler | null) => {
|
|
125
|
+
backHandlerRef.current = handler;
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const api = useMemo<NavigationAPI>(() => {
|
|
129
|
+
const stack = state.stack;
|
|
130
|
+
const modalStack = state.modalStack;
|
|
131
|
+
const current = stack[stack.length - 1]!;
|
|
132
|
+
const currentModal = modalStack[modalStack.length - 1];
|
|
133
|
+
|
|
134
|
+
const goBack = () => {
|
|
135
|
+
// 1. If modal is open, close it
|
|
136
|
+
if (modalStack.length > 0) {
|
|
137
|
+
dispatch({ type: "closeModal" });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. Let the screen's back handler try first
|
|
142
|
+
if (backHandlerRef.current) {
|
|
143
|
+
const handled = backHandlerRef.current();
|
|
144
|
+
if (handled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Pop the stack if possible
|
|
150
|
+
if (stack.length > 1) {
|
|
151
|
+
dispatch({ type: "pop" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. At root, call onExit
|
|
156
|
+
onExit?.();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
current,
|
|
161
|
+
stack,
|
|
162
|
+
push: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
|
|
163
|
+
dispatch({ type: "push", screen: { route, params, meta } }),
|
|
164
|
+
replace: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
|
|
165
|
+
dispatch({ type: "replace", screen: { route, params, meta } }),
|
|
166
|
+
reset: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
|
|
167
|
+
dispatch({ type: "reset", screen: { route, params, meta } }),
|
|
168
|
+
pop: () => dispatch({ type: "pop" }),
|
|
169
|
+
canGoBack: stack.length > 1 || modalStack.length > 0,
|
|
170
|
+
modalStack,
|
|
171
|
+
currentModal,
|
|
172
|
+
openModal: <TParams,>(id: string, params?: TParams) =>
|
|
173
|
+
dispatch({ type: "openModal", modal: { id, params } }),
|
|
174
|
+
closeModal: () => dispatch({ type: "closeModal" }),
|
|
175
|
+
hasModal: modalStack.length > 0,
|
|
176
|
+
goBack,
|
|
177
|
+
setBackHandler,
|
|
178
|
+
};
|
|
179
|
+
}, [state, onExit, setBackHandler]);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<NavigationContext.Provider value={api}>
|
|
183
|
+
{children}
|
|
184
|
+
</NavigationContext.Provider>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function useNavigation(): NavigationAPI {
|
|
189
|
+
const context = useContext(NavigationContext);
|
|
190
|
+
if (!context) {
|
|
191
|
+
throw new Error("useNavigation must be used within a NavigationProvider");
|
|
192
|
+
}
|
|
193
|
+
return context;
|
|
194
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { Renderer } from "../adapters/types.ts";
|
|
3
|
+
|
|
4
|
+
const RendererContext = createContext<Renderer | null>(null);
|
|
5
|
+
|
|
6
|
+
export function RendererProvider({ renderer, children }: { renderer: Renderer; children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<RendererContext.Provider value={renderer}>
|
|
9
|
+
{children}
|
|
10
|
+
</RendererContext.Provider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useRenderer(): Renderer {
|
|
15
|
+
const renderer = useContext(RendererContext);
|
|
16
|
+
if (!renderer) {
|
|
17
|
+
throw new Error("useRenderer must be used within RendererProvider");
|
|
18
|
+
}
|
|
19
|
+
return renderer;
|
|
20
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
} from "react";
|
|
6
|
+
import type { AnyCommand } from "../../core/command.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* App-level context value - provides app info and commands to screens.
|
|
10
|
+
*/
|
|
11
|
+
export interface TuiAppContextValue {
|
|
12
|
+
/** Application name (used for persistence keys, CLI commands, etc.) */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Display name for the header */
|
|
15
|
+
displayName?: string;
|
|
16
|
+
/** Application version */
|
|
17
|
+
version: string;
|
|
18
|
+
/** All available commands */
|
|
19
|
+
commands: AnyCommand[];
|
|
20
|
+
/** Exit the TUI application */
|
|
21
|
+
onExit: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TuiAppContext = createContext<TuiAppContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
interface TuiAppContextProviderProps extends TuiAppContextValue {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Provider that gives screens access to app-level information.
|
|
32
|
+
*/
|
|
33
|
+
export function TuiAppContextProvider({
|
|
34
|
+
children,
|
|
35
|
+
name,
|
|
36
|
+
displayName,
|
|
37
|
+
version,
|
|
38
|
+
commands,
|
|
39
|
+
onExit,
|
|
40
|
+
}: TuiAppContextProviderProps) {
|
|
41
|
+
return (
|
|
42
|
+
<TuiAppContext.Provider value={{ name, displayName, version, commands, onExit }}>
|
|
43
|
+
{children}
|
|
44
|
+
</TuiAppContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Access the TUI app context.
|
|
50
|
+
* @throws Error if used outside of TuiAppContextProvider
|
|
51
|
+
*/
|
|
52
|
+
export function useTuiApp(): TuiAppContextValue {
|
|
53
|
+
const context = useContext(TuiAppContext);
|
|
54
|
+
if (!context) {
|
|
55
|
+
throw new Error("useTuiApp must be used within a TuiAppContextProvider");
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
}
|