@pablozaiden/terminatui 0.1.2 → 0.3.0-beta-1
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/AGENTS.md +43 -0
- package/CLAUDE.md +1 -0
- package/README.md +64 -43
- package/bun.lock +85 -0
- package/examples/tui-app/commands/config/app/get.ts +62 -0
- package/examples/tui-app/commands/config/app/index.ts +23 -0
- package/examples/tui-app/commands/config/app/set.ts +96 -0
- package/examples/tui-app/commands/config/index.ts +28 -0
- package/examples/tui-app/commands/config/user/get.ts +61 -0
- package/examples/tui-app/commands/config/user/index.ts +23 -0
- package/examples/tui-app/commands/config/user/set.ts +57 -0
- package/examples/tui-app/commands/greet.ts +14 -11
- package/examples/tui-app/commands/math.ts +6 -9
- package/examples/tui-app/commands/status.ts +24 -13
- package/examples/tui-app/index.ts +7 -3
- package/guides/01-hello-world.md +7 -2
- package/guides/02-adding-options.md +2 -2
- package/guides/03-multiple-commands.md +6 -8
- package/guides/04-subcommands.md +8 -8
- package/guides/05-interactive-tui.md +45 -30
- package/guides/06-config-validation.md +4 -12
- package/guides/07-async-cancellation.md +15 -69
- package/guides/08-complete-application.md +13 -179
- package/guides/README.md +7 -3
- package/package.json +4 -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__/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 +19 -4
- package/src/builtins/settings.ts +18 -32
- package/src/builtins/version.ts +4 -4
- 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/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 +135 -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 +115 -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 +70 -0
- package/src/tui/components/ActionButton.tsx +0 -36
- package/src/tui/components/CommandSelector.tsx +52 -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 +160 -0
- package/src/tui/screens/ErrorScreen.tsx +58 -0
- package/src/tui/screens/ResultsScreen.tsx +60 -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/examples/tui-app/commands/index.ts +0 -3
- 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/index.ts +0 -137
- package/src/registry/commandRegistry.ts +0 -77
- package/src/registry/index.ts +0 -1
- package/src/tui/TuiApp.tsx +0 -582
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useId, useRef } from "react";
|
|
2
|
+
import { useKeyboardContext } from "../context/KeyboardContext.tsx";
|
|
3
|
+
import type { KeyHandler } from "../adapters/types.ts";
|
|
4
|
+
|
|
5
|
+
interface UseActiveKeyHandlerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Whether the handler is currently enabled.
|
|
8
|
+
* When false, the handler is unregistered.
|
|
9
|
+
* @default true
|
|
10
|
+
*/
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register as the active keyboard handler.
|
|
16
|
+
*
|
|
17
|
+
* Only ONE handler is active at a time - the most recently registered enabled handler.
|
|
18
|
+
* When a modal opens and calls this hook, it becomes the active handler.
|
|
19
|
+
* When it unmounts or becomes disabled, the previous handler is restored.
|
|
20
|
+
*
|
|
21
|
+
* The handler receives keys AFTER global shortcuts (Ctrl+L, Ctrl+Y, Esc) are processed.
|
|
22
|
+
* Return true from the handler if the key was handled.
|
|
23
|
+
*
|
|
24
|
+
* @param handler - Callback invoked on keyboard events. Return true if handled.
|
|
25
|
+
* @param options - Optional configuration (e.g., enabled flag).
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* // In a screen component
|
|
30
|
+
* useActiveKeyHandler((event) => {
|
|
31
|
+
* if (event.name === "return" || event.name === "enter") {
|
|
32
|
+
* onSelect();
|
|
33
|
+
* return true;
|
|
34
|
+
* }
|
|
35
|
+
* return false;
|
|
36
|
+
* });
|
|
37
|
+
*
|
|
38
|
+
* // In a modal - becomes active when visible
|
|
39
|
+
* useActiveKeyHandler(
|
|
40
|
+
* (event) => {
|
|
41
|
+
* if (event.name === "escape") {
|
|
42
|
+
* onClose();
|
|
43
|
+
* return true;
|
|
44
|
+
* }
|
|
45
|
+
* return false;
|
|
46
|
+
* },
|
|
47
|
+
* { enabled: visible }
|
|
48
|
+
* );
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function useActiveKeyHandler(
|
|
52
|
+
handler: KeyHandler,
|
|
53
|
+
options: UseActiveKeyHandlerOptions = {}
|
|
54
|
+
): void {
|
|
55
|
+
const { enabled = true } = options;
|
|
56
|
+
const { setActiveHandler } = useKeyboardContext();
|
|
57
|
+
const id = useId();
|
|
58
|
+
const handlerRef = useRef(handler);
|
|
59
|
+
|
|
60
|
+
// Keep ref updated without triggering re-registration
|
|
61
|
+
handlerRef.current = handler;
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Register a stable wrapper that calls the current handler
|
|
69
|
+
const unregister = setActiveHandler(id, (event) => handlerRef.current(event));
|
|
70
|
+
return unregister;
|
|
71
|
+
}, [id, enabled, setActiveHandler]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Re-export types for convenience
|
|
75
|
+
export type { KeyHandler };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useNavigation, type BackHandler } from "../context/NavigationContext.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register a back handler for the current screen.
|
|
6
|
+
*
|
|
7
|
+
* When the user presses Esc (or calls navigation.goBack()), this handler
|
|
8
|
+
* is called. Return true if you handled the back action, false to let
|
|
9
|
+
* navigation proceed with default behavior (pop stack or exit).
|
|
10
|
+
*
|
|
11
|
+
* The handler is automatically unregistered when the component unmounts.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* // In a screen with special back behavior
|
|
16
|
+
* useBackHandler(() => {
|
|
17
|
+
* if (hasUnsavedChanges) {
|
|
18
|
+
* showConfirmDialog();
|
|
19
|
+
* return true; // We handled it
|
|
20
|
+
* }
|
|
21
|
+
* return false; // Let navigation pop
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useBackHandler(handler: BackHandler): void {
|
|
26
|
+
const navigation = useNavigation();
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
navigation.setBackHandler(handler);
|
|
30
|
+
return () => {
|
|
31
|
+
navigation.setBackHandler(null);
|
|
32
|
+
};
|
|
33
|
+
}, [navigation, handler]);
|
|
34
|
+
}
|
|
@@ -1,39 +1,55 @@
|
|
|
1
1
|
import { useCallback, useState } from "react";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
* Copy text to clipboard using OSC 52 escape sequence.
|
|
6
|
-
* Write directly to /dev/tty to bypass any stdout interception.
|
|
7
|
-
*/
|
|
4
|
+
|
|
8
5
|
function copyWithOsc52(text: string): boolean {
|
|
9
6
|
try {
|
|
10
|
-
|
|
11
|
-
const cleanText = typeof Bun !== "undefined"
|
|
12
|
-
? Bun.stripANSI(text)
|
|
13
|
-
: text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
|
7
|
+
const cleanText = Bun.stripANSI(text);
|
|
14
8
|
const base64 = Buffer.from(cleanText).toString("base64");
|
|
15
|
-
// OSC 52 sequence: ESC ] 52 ; c ; <base64> BEL
|
|
16
9
|
const osc52 = `\x1b]52;c;${base64}\x07`;
|
|
17
|
-
|
|
18
|
-
// Try to write directly to the TTY to bypass OpenTUI's stdout capture
|
|
10
|
+
|
|
19
11
|
try {
|
|
20
|
-
const fd = fs.openSync(
|
|
12
|
+
const fd = fs.openSync("/dev/tty", "w");
|
|
21
13
|
fs.writeSync(fd, osc52);
|
|
22
14
|
fs.closeSync(fd);
|
|
23
15
|
} catch {
|
|
24
|
-
// Fallback to stdout if /dev/tty is not available
|
|
25
16
|
process.stdout.write(osc52);
|
|
26
17
|
}
|
|
27
|
-
|
|
18
|
+
|
|
28
19
|
return true;
|
|
29
20
|
} catch {
|
|
30
21
|
return false;
|
|
31
22
|
}
|
|
32
23
|
}
|
|
33
24
|
|
|
25
|
+
async function copyWithPbcopy(text: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const cleanText = Bun.stripANSI(text);
|
|
28
|
+
const proc = Bun.spawn(["pbcopy"], {
|
|
29
|
+
stdin: "pipe",
|
|
30
|
+
stdout: "ignore",
|
|
31
|
+
stderr: "ignore",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
proc.stdin.write(cleanText);
|
|
35
|
+
proc.stdin.end();
|
|
36
|
+
|
|
37
|
+
const exitCode = await proc.exited;
|
|
38
|
+
return exitCode === 0;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
45
|
+
if (process.env["TERM_PROGRAM"] === "Apple_Terminal") {
|
|
46
|
+
return await copyWithPbcopy(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return copyWithOsc52(text);
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
export interface UseClipboardResult {
|
|
35
|
-
/** Copy text to clipboard */
|
|
36
|
-
copy: (text: string) => boolean;
|
|
37
53
|
/** Last action message for status display */
|
|
38
54
|
lastAction: string;
|
|
39
55
|
/** Set the last action message */
|
|
@@ -48,19 +64,18 @@ export interface UseClipboardResult {
|
|
|
48
64
|
*/
|
|
49
65
|
export function useClipboard(): UseClipboardResult {
|
|
50
66
|
const [lastAction, setLastAction] = useState("");
|
|
51
|
-
|
|
52
|
-
const copy = useCallback((text: string): boolean => {
|
|
53
|
-
return copyWithOsc52(text);
|
|
54
|
-
}, []);
|
|
55
67
|
|
|
56
68
|
const copyWithMessage = useCallback((text: string, label: string) => {
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
void (async () => {
|
|
70
|
+
const success = await copyToClipboard(text);
|
|
71
|
+
if (!success) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
setLastAction(`✓ ${label} copied to clipboard`);
|
|
60
|
-
// Clear message after 2 seconds
|
|
61
76
|
setTimeout(() => setLastAction(""), 2000);
|
|
62
|
-
}
|
|
77
|
+
})();
|
|
63
78
|
}, []);
|
|
64
79
|
|
|
65
|
-
return {
|
|
80
|
+
return { lastAction, setLastAction, copyWithMessage };
|
|
66
81
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useId } from "react";
|
|
2
|
+
import { useClipboardContext, type ClipboardContent, type ClipboardProvider } from "../context/ClipboardContext.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook for registering a clipboard provider.
|
|
6
|
+
* The provider is automatically registered when mounted and unregistered when unmounted.
|
|
7
|
+
*
|
|
8
|
+
* @param provider - Function that returns clipboard content or null
|
|
9
|
+
* @param enabled - Whether the provider is active (default: true)
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* // In a screen component
|
|
14
|
+
* useClipboardProvider(() => ({
|
|
15
|
+
* content: JSON.stringify(values, null, 2),
|
|
16
|
+
* label: "Config"
|
|
17
|
+
* }));
|
|
18
|
+
*
|
|
19
|
+
* // In a modal that may or may not have content
|
|
20
|
+
* useClipboardProvider(() => {
|
|
21
|
+
* if (!hasContent) return null;
|
|
22
|
+
* return { content: data, label: "Modal Data" };
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function useClipboardProvider(
|
|
27
|
+
provider: ClipboardProvider,
|
|
28
|
+
enabled: boolean = true
|
|
29
|
+
): void {
|
|
30
|
+
const { register } = useClipboardContext();
|
|
31
|
+
const id = useId();
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!enabled) return;
|
|
35
|
+
|
|
36
|
+
const unregister = register(id, provider);
|
|
37
|
+
return unregister;
|
|
38
|
+
}, [id, provider, enabled, register]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Re-export types for convenience
|
|
42
|
+
export type { ClipboardContent, ClipboardProvider };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useKeyboardContext, type GlobalKeyHandler } from "../context/KeyboardContext.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Set the global keyboard handler.
|
|
6
|
+
*
|
|
7
|
+
* The global handler receives ALL key events FIRST, before the active handler.
|
|
8
|
+
* Use this for app-wide shortcuts like Ctrl+L (logs), Ctrl+Y (copy), Esc (back).
|
|
9
|
+
*
|
|
10
|
+
* Return true from the handler if the key was handled (prevents active handler from receiving it).
|
|
11
|
+
*
|
|
12
|
+
* Only ONE global handler is supported - typically set by the main app component.
|
|
13
|
+
*
|
|
14
|
+
* @param handler - Callback invoked on all keyboard events. Return true if handled.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* // In TuiRoot
|
|
19
|
+
* useGlobalKeyHandler((event) => {
|
|
20
|
+
* const key = event;
|
|
21
|
+
*
|
|
22
|
+
* if (key.ctrl && key.name === "l") {
|
|
23
|
+
* toggleLogs();
|
|
24
|
+
* return true;
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* if (key.name === "escape") {
|
|
28
|
+
* handleBack();
|
|
29
|
+
* return true;
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* return false; // Let active handler process
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function useGlobalKeyHandler(handler: GlobalKeyHandler): void {
|
|
37
|
+
const { setGlobalHandler } = useKeyboardContext();
|
|
38
|
+
const handlerRef = useRef(handler);
|
|
39
|
+
|
|
40
|
+
// Keep ref updated without triggering re-registration
|
|
41
|
+
handlerRef.current = handler;
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
// Set a stable wrapper that calls the current handler
|
|
45
|
+
const unregister = setGlobalHandler((event) => handlerRef.current(event));
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
unregister();
|
|
49
|
+
};
|
|
50
|
+
}, [setGlobalHandler]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Re-export types for convenience
|
|
54
|
+
export type { GlobalKeyHandler };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
|
|
2
|
+
import { Container } from "../semantic/Container.tsx";
|
|
3
|
+
import { ScrollView } from "../semantic/ScrollView.tsx";
|
|
4
|
+
import { ModalBase } from "../components/ModalBase.tsx";
|
|
5
|
+
import { useClipboardProvider } from "../hooks/useClipboardProvider.ts";
|
|
6
|
+
import { Label } from "../semantic/Label.tsx";
|
|
7
|
+
import { Value } from "../semantic/Value.tsx";
|
|
8
|
+
import type { ModalComponent, ModalDefinition } from "../registry.ts";
|
|
9
|
+
|
|
10
|
+
export interface CliModalParams {
|
|
11
|
+
command: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CliModal implements ModalDefinition<CliModalParams> {
|
|
15
|
+
static readonly Id = "cli";
|
|
16
|
+
|
|
17
|
+
getId(): string {
|
|
18
|
+
return CliModal.Id;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
component(): ModalComponent<CliModalParams> {
|
|
22
|
+
return function CliModalComponentWrapper({ params, onClose }: { params: CliModalParams; onClose: () => void; }) {
|
|
23
|
+
return (
|
|
24
|
+
<CliModalView
|
|
25
|
+
command={params.command}
|
|
26
|
+
visible={true}
|
|
27
|
+
onClose={onClose}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CliModalViewProps extends CliModalParams {
|
|
35
|
+
/** Whether the modal is visible */
|
|
36
|
+
visible: boolean;
|
|
37
|
+
/** Called when the modal should close */
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Modal displaying the CLI command equivalent of the current config.
|
|
43
|
+
*/
|
|
44
|
+
function CliModalView({
|
|
45
|
+
command,
|
|
46
|
+
visible,
|
|
47
|
+
onClose,
|
|
48
|
+
}: CliModalViewProps) {
|
|
49
|
+
// Register clipboard provider for CLI command
|
|
50
|
+
useClipboardProvider(
|
|
51
|
+
() => ({ content: command, label: "CLI" }),
|
|
52
|
+
visible
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Handle Enter to close (Esc is handled globally)
|
|
56
|
+
useActiveKeyHandler(
|
|
57
|
+
(event) => {
|
|
58
|
+
if (event.name === "return" || event.name === "enter") {
|
|
59
|
+
onClose();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
},
|
|
64
|
+
{ enabled: visible }
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (!visible) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<ModalBase title="CLI Command" width="80%" height={10} top={4} left={4}>
|
|
73
|
+
<ScrollView axis="horizontal" height={3}>
|
|
74
|
+
<Container>
|
|
75
|
+
<Value>{command}</Value>
|
|
76
|
+
</Container>
|
|
77
|
+
</ScrollView>
|
|
78
|
+
|
|
79
|
+
<Label color="mutedText">Enter or Esc to close</Label>
|
|
80
|
+
</ModalBase>
|
|
81
|
+
);
|
|
82
|
+
}
|