@pablozaiden/terminatui 0.3.0 → 0.4.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/package.json +1 -1
- package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
- package/src/__tests__/schemaToFields.test.ts +0 -4
- package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
- package/src/index.ts +2 -2
- package/src/tui/TuiApplication.tsx +0 -4
- package/src/tui/TuiRoot.tsx +58 -102
- package/src/tui/actions.ts +4 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +191 -45
- package/src/tui/adapters/ink/SemanticInkRenderer.tsx +210 -0
- package/src/tui/adapters/ink/components/Button.tsx +10 -2
- package/src/tui/adapters/ink/components/Overlay.tsx +8 -2
- package/src/tui/adapters/ink/components/Panel.tsx +26 -5
- package/src/tui/adapters/ink/components/ScrollView.tsx +44 -3
- package/src/tui/adapters/ink/components/Spinner.tsx +8 -2
- package/src/tui/adapters/ink/keyboard.ts +0 -3
- package/src/tui/adapters/ink/ui/CommandSelector.tsx +56 -0
- package/src/tui/adapters/ink/ui/ConfigForm.tsx +77 -0
- package/src/tui/adapters/ink/ui/Header.tsx +25 -0
- package/src/tui/adapters/ink/ui/JsonHighlight.tsx +21 -0
- package/src/tui/adapters/ink/ui/ResultsPanel.tsx +57 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +190 -43
- package/src/tui/adapters/opentui/SemanticOpenTuiRenderer.tsx +192 -0
- package/src/tui/adapters/opentui/components/Label.tsx +2 -2
- package/src/tui/adapters/opentui/components/Overlay.tsx +12 -3
- package/src/tui/adapters/opentui/components/Panel.tsx +11 -1
- package/src/tui/adapters/opentui/components/ScrollView.tsx +1 -8
- package/src/tui/adapters/opentui/components/Spinner.tsx +1 -1
- package/src/tui/adapters/opentui/keyboard.ts +0 -3
- package/src/tui/adapters/opentui/ui/CommandSelector.tsx +55 -0
- package/src/tui/adapters/opentui/ui/ConfigForm.tsx +74 -0
- package/src/tui/adapters/opentui/ui/Header.tsx +24 -0
- package/src/tui/adapters/opentui/ui/JsonHighlight.tsx +20 -0
- package/src/tui/adapters/opentui/ui/LogsPanel.tsx +44 -0
- package/src/tui/adapters/opentui/ui/ResultsPanel.tsx +62 -0
- package/src/tui/adapters/shared/TerminalClipboard.ts +65 -0
- package/src/tui/adapters/{opentui/hooks → shared}/useSpinner.ts +5 -1
- package/src/tui/adapters/types.ts +25 -46
- package/src/tui/components/JsonHighlight.tsx +41 -111
- package/src/tui/context/ActionContext.tsx +51 -0
- package/src/tui/context/ExecutorContext.tsx +7 -1
- package/src/tui/context/NavigationContext.tsx +20 -4
- package/src/tui/controllers/CommandBrowserController.tsx +100 -0
- package/src/tui/controllers/ConfigController.tsx +183 -0
- package/src/tui/controllers/EditorController.tsx +169 -0
- package/src/tui/controllers/LogsController.tsx +48 -0
- package/src/tui/controllers/OutcomeController.tsx +110 -0
- package/src/tui/driver/TuiDriver.tsx +148 -0
- package/src/tui/driver/context/TuiDriverContext.tsx +44 -0
- package/src/tui/driver/types.ts +72 -0
- package/src/tui/semantic/AppShell.tsx +30 -0
- package/src/tui/semantic/CommandBrowserScreen.tsx +16 -0
- package/src/tui/semantic/ConfigScreen.tsx +23 -0
- package/src/tui/semantic/EditorScreen.tsx +20 -0
- package/src/tui/semantic/LogsScreen.tsx +9 -0
- package/src/tui/semantic/RunningScreen.tsx +17 -0
- package/src/tui/semantic/layoutTypes.ts +72 -0
- package/src/tui/semantic/render.tsx +44 -0
- package/src/tui/semantic/types.ts +31 -98
- package/src/tui/utils/jsonTokenizer.ts +98 -0
- package/src/tui/utils/schemaToFields.ts +1 -25
- package/src/tui/adapters/ink/components/Code.tsx +0 -6
- package/src/tui/adapters/ink/components/Container.tsx +0 -5
- package/src/tui/adapters/ink/components/Spacer.tsx +0 -15
- package/src/tui/adapters/ink/components/Value.tsx +0 -7
- package/src/tui/adapters/opentui/components/Code.tsx +0 -12
- package/src/tui/adapters/opentui/components/Container.tsx +0 -56
- package/src/tui/adapters/opentui/components/Spacer.tsx +0 -5
- package/src/tui/adapters/opentui/components/Value.tsx +0 -13
- package/src/tui/components/ActionButton.tsx +0 -0
- package/src/tui/components/CommandSelector.tsx +0 -119
- package/src/tui/components/ConfigForm.tsx +0 -174
- package/src/tui/components/FieldRow.tsx +0 -0
- package/src/tui/components/Header.tsx +0 -32
- package/src/tui/components/ModalBase.tsx +0 -38
- package/src/tui/components/ResultsPanel.tsx +0 -84
- package/src/tui/components/StatusBar.tsx +0 -44
- package/src/tui/components/logColors.ts +0 -12
- package/src/tui/components/types.ts +0 -30
- package/src/tui/context/ClipboardContext.tsx +0 -87
- package/src/tui/context/KeyboardContext.tsx +0 -132
- package/src/tui/hooks/useActiveKeyHandler.ts +0 -75
- package/src/tui/hooks/useClipboard.ts +0 -81
- package/src/tui/hooks/useClipboardProvider.ts +0 -42
- package/src/tui/hooks/useGlobalKeyHandler.ts +0 -54
- package/src/tui/modals/CliModal.tsx +0 -82
- package/src/tui/modals/EditorModal.tsx +0 -207
- package/src/tui/modals/LogsModal.tsx +0 -98
- package/src/tui/registry.ts +0 -102
- package/src/tui/screens/CommandSelectScreen.tsx +0 -162
- package/src/tui/screens/ConfigScreen.tsx +0 -165
- package/src/tui/screens/ErrorScreen.tsx +0 -58
- package/src/tui/screens/ResultsScreen.tsx +0 -68
- package/src/tui/screens/RunningScreen.tsx +0 -72
- package/src/tui/screens/ScreenBase.ts +0 -6
- package/src/tui/semantic/Button.tsx +0 -7
- package/src/tui/semantic/Code.tsx +0 -7
- package/src/tui/semantic/CodeHighlight.tsx +0 -7
- package/src/tui/semantic/Container.tsx +0 -7
- package/src/tui/semantic/Field.tsx +0 -7
- package/src/tui/semantic/Label.tsx +0 -7
- package/src/tui/semantic/MenuButton.tsx +0 -7
- package/src/tui/semantic/MenuItem.tsx +0 -7
- package/src/tui/semantic/Overlay.tsx +0 -7
- package/src/tui/semantic/Panel.tsx +0 -7
- package/src/tui/semantic/ScrollView.tsx +0 -9
- package/src/tui/semantic/Select.tsx +0 -7
- package/src/tui/semantic/Spacer.tsx +0 -7
- package/src/tui/semantic/Spinner.tsx +0 -7
- package/src/tui/semantic/TextInput.tsx +0 -7
- package/src/tui/semantic/Value.tsx +0 -7
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import type { CommandResult } from "../../core/command.ts";
|
|
3
|
-
import { Container } from "../semantic/Container.tsx";
|
|
4
|
-
import { Panel } from "../semantic/Panel.tsx";
|
|
5
|
-
import { ScrollView } from "../semantic/ScrollView.tsx";
|
|
6
|
-
import { Label } from "../semantic/Label.tsx";
|
|
7
|
-
import { Value } from "../semantic/Value.tsx";
|
|
8
|
-
|
|
9
|
-
interface ResultsPanelProps {
|
|
10
|
-
/** The result to display */
|
|
11
|
-
result: CommandResult | null;
|
|
12
|
-
/** Error to display (if any) */
|
|
13
|
-
error: Error | null;
|
|
14
|
-
/** Whether the panel is focused */
|
|
15
|
-
focused: boolean;
|
|
16
|
-
/** Custom result renderer */
|
|
17
|
-
renderResult?: (result: CommandResult) => ReactNode;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Panel displaying command execution results.
|
|
22
|
-
*/
|
|
23
|
-
export function ResultsPanel({
|
|
24
|
-
result,
|
|
25
|
-
error,
|
|
26
|
-
focused,
|
|
27
|
-
renderResult,
|
|
28
|
-
}: ResultsPanelProps) {
|
|
29
|
-
|
|
30
|
-
// Determine content to display
|
|
31
|
-
let content: ReactNode;
|
|
32
|
-
|
|
33
|
-
if (error) {
|
|
34
|
-
content = (
|
|
35
|
-
<Container flexDirection="column" gap={1}>
|
|
36
|
-
<Label color="error" bold>
|
|
37
|
-
Error
|
|
38
|
-
</Label>
|
|
39
|
-
<Label color="error">{error.message}</Label>
|
|
40
|
-
</Container>
|
|
41
|
-
);
|
|
42
|
-
} else if (result) {
|
|
43
|
-
if (renderResult) {
|
|
44
|
-
const customContent = renderResult(result);
|
|
45
|
-
|
|
46
|
-
if (typeof customContent === "string" || typeof customContent === "number" || typeof customContent === "boolean") {
|
|
47
|
-
// Wrap primitive results so the renderer gets a text node
|
|
48
|
-
content = <Value>{String(customContent)}</Value>;
|
|
49
|
-
} else {
|
|
50
|
-
content = customContent as ReactNode;
|
|
51
|
-
}
|
|
52
|
-
} else {
|
|
53
|
-
// Default JSON display
|
|
54
|
-
content = (
|
|
55
|
-
<Container flexDirection="column" gap={1}>
|
|
56
|
-
{result.message && (
|
|
57
|
-
<Label color={result.success ? "success" : "error"}>{result.message}</Label>
|
|
58
|
-
)}
|
|
59
|
-
{result.data !== undefined && result.data !== null && (
|
|
60
|
-
<Value>{JSON.stringify(result.data, null, 2)}</Value>
|
|
61
|
-
)}
|
|
62
|
-
</Container>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
content = <Label color="mutedText">No results yet...</Label>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<Panel
|
|
71
|
-
title="Results"
|
|
72
|
-
focused={focused}
|
|
73
|
-
flex={1}
|
|
74
|
-
padding={1}
|
|
75
|
-
flexDirection="column"
|
|
76
|
-
>
|
|
77
|
-
<ScrollView axis="vertical" flex={1} focused={focused}>
|
|
78
|
-
<Container flexDirection="column">
|
|
79
|
-
{content}
|
|
80
|
-
</Container>
|
|
81
|
-
</ScrollView>
|
|
82
|
-
</Panel>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { Label } from "../semantic/Label.tsx";
|
|
2
|
-
import { Spinner } from "../semantic/Spinner.tsx";
|
|
3
|
-
import { Panel } from "../semantic/Panel.tsx";
|
|
4
|
-
import { Container } from "../semantic/Container.tsx";
|
|
5
|
-
|
|
6
|
-
interface StatusBarProps {
|
|
7
|
-
/** Status message to display */
|
|
8
|
-
status: string;
|
|
9
|
-
/** Whether the app is currently running a command */
|
|
10
|
-
isRunning?: boolean;
|
|
11
|
-
/** Whether to show keyboard shortcuts */
|
|
12
|
-
showShortcuts?: boolean;
|
|
13
|
-
/** Custom shortcuts string (defaults to standard shortcuts) */
|
|
14
|
-
shortcuts?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Status bar showing current status, spinner, and keyboard shortcuts.
|
|
19
|
-
*/
|
|
20
|
-
export function StatusBar({
|
|
21
|
-
status,
|
|
22
|
-
isRunning = false,
|
|
23
|
-
showShortcuts = true,
|
|
24
|
-
shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back",
|
|
25
|
-
}: StatusBarProps) {
|
|
26
|
-
return (
|
|
27
|
-
<Panel dense border={true} flexDirection="column" gap={0} height={showShortcuts ? 4 : 2}>
|
|
28
|
-
<Container flexDirection="row" justifyContent="space-between" padding={{ left: 1, right: 1 }}>
|
|
29
|
-
<Container flexDirection="row">
|
|
30
|
-
<Spinner active={isRunning} />
|
|
31
|
-
<Label color="success" bold>
|
|
32
|
-
{status}
|
|
33
|
-
</Label>
|
|
34
|
-
</Container>
|
|
35
|
-
</Container>
|
|
36
|
-
|
|
37
|
-
{showShortcuts ? (
|
|
38
|
-
<Container padding={{ left: 1, right: 1 }}>
|
|
39
|
-
<Label color="mutedText">{shortcuts}</Label>
|
|
40
|
-
</Container>
|
|
41
|
-
) : null}
|
|
42
|
-
</Panel>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { LogLevel } from "../../core/logger";
|
|
2
|
-
|
|
3
|
-
// Shared colors for log levels used across debug views.
|
|
4
|
-
export const LogColors: Record<LogLevel, string> = {
|
|
5
|
-
[LogLevel.silly]: "#8c8c8c",
|
|
6
|
-
[LogLevel.trace]: "#6dd6ff",
|
|
7
|
-
[LogLevel.debug]: "#7bdcb5",
|
|
8
|
-
[LogLevel.info]: "#d6dde6",
|
|
9
|
-
[LogLevel.warn]: "#f5c542",
|
|
10
|
-
[LogLevel.error]: "#f78888",
|
|
11
|
-
[LogLevel.fatal]: "#ff5c8d",
|
|
12
|
-
};
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Field type for TUI forms.
|
|
3
|
-
*/
|
|
4
|
-
export type FieldType = "text" | "number" | "enum" | "boolean";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Option for enum/select fields.
|
|
8
|
-
*/
|
|
9
|
-
export interface FieldOption {
|
|
10
|
-
name: string;
|
|
11
|
-
value: unknown;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Field configuration for TUI forms.
|
|
16
|
-
*/
|
|
17
|
-
export interface FieldConfig {
|
|
18
|
-
/** Field key (must match a key in values) */
|
|
19
|
-
key: string;
|
|
20
|
-
/** Display label */
|
|
21
|
-
label: string;
|
|
22
|
-
/** Field type */
|
|
23
|
-
type: FieldType;
|
|
24
|
-
/** Options for enum type */
|
|
25
|
-
options?: FieldOption[];
|
|
26
|
-
/** Placeholder text for input fields */
|
|
27
|
-
placeholder?: string;
|
|
28
|
-
/** Group name for organizing fields */
|
|
29
|
-
group?: string;
|
|
30
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext, useRef, useCallback, type ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Clipboard content that can be provided by a screen or modal.
|
|
5
|
-
*/
|
|
6
|
-
export interface ClipboardContent {
|
|
7
|
-
content: string;
|
|
8
|
-
label: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Provider function that returns clipboard content or null.
|
|
13
|
-
*/
|
|
14
|
-
export type ClipboardProvider = () => ClipboardContent | null;
|
|
15
|
-
|
|
16
|
-
interface ClipboardContextValue {
|
|
17
|
-
/**
|
|
18
|
-
* Register a clipboard provider. Returns an unregister function.
|
|
19
|
-
* Providers are stacked - the most recently registered provider is checked first.
|
|
20
|
-
*/
|
|
21
|
-
register: (id: string, provider: ClipboardProvider) => () => void;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Get clipboard content from the topmost provider that returns content.
|
|
25
|
-
*/
|
|
26
|
-
getContent: () => ClipboardContent | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const ClipboardContext = createContext<ClipboardContextValue | null>(null);
|
|
30
|
-
|
|
31
|
-
interface ClipboardProviderProps {
|
|
32
|
-
children: ReactNode;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Provider that manages clipboard content providers from screens and modals.
|
|
37
|
-
* Providers are stacked - modals register on top of screens, so modal content
|
|
38
|
-
* takes precedence when copying.
|
|
39
|
-
*/
|
|
40
|
-
export function ClipboardProviderComponent({ children }: ClipboardProviderProps) {
|
|
41
|
-
const providersRef = useRef<Map<string, ClipboardProvider>>(new Map());
|
|
42
|
-
const orderRef = useRef<string[]>([]);
|
|
43
|
-
|
|
44
|
-
const register = useCallback((id: string, provider: ClipboardProvider) => {
|
|
45
|
-
providersRef.current.set(id, provider);
|
|
46
|
-
// Add to end (most recent)
|
|
47
|
-
orderRef.current = orderRef.current.filter((i) => i !== id);
|
|
48
|
-
orderRef.current.push(id);
|
|
49
|
-
|
|
50
|
-
return () => {
|
|
51
|
-
providersRef.current.delete(id);
|
|
52
|
-
orderRef.current = orderRef.current.filter((i) => i !== id);
|
|
53
|
-
};
|
|
54
|
-
}, []);
|
|
55
|
-
|
|
56
|
-
const getContent = useCallback((): ClipboardContent | null => {
|
|
57
|
-
// Check providers in reverse order (most recent first)
|
|
58
|
-
for (let i = orderRef.current.length - 1; i >= 0; i--) {
|
|
59
|
-
const id = orderRef.current[i];
|
|
60
|
-
const provider = providersRef.current.get(id!);
|
|
61
|
-
if (provider) {
|
|
62
|
-
const content = provider();
|
|
63
|
-
if (content) {
|
|
64
|
-
return content;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<ClipboardContext.Provider value={{ register, getContent }}>
|
|
73
|
-
{children}
|
|
74
|
-
</ClipboardContext.Provider>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Access the clipboard context.
|
|
80
|
-
*/
|
|
81
|
-
export function useClipboardContext(): ClipboardContextValue {
|
|
82
|
-
const context = useContext(ClipboardContext);
|
|
83
|
-
if (!context) {
|
|
84
|
-
throw new Error("useClipboardContext must be used within a ClipboardProviderComponent");
|
|
85
|
-
}
|
|
86
|
-
return context;
|
|
87
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useContext,
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
10
|
-
import type { KeyboardEvent, KeyHandler } from "../adapters/types.ts";
|
|
11
|
-
import { useRenderer } from "./RendererContext.tsx";
|
|
12
|
-
|
|
13
|
-
function useRendererKeyboard() {
|
|
14
|
-
return useRenderer().keyboard;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export type GlobalKeyHandler = (event: KeyboardEvent) => boolean;
|
|
19
|
-
|
|
20
|
-
interface KeyboardContextValue {
|
|
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;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
|
|
45
|
-
|
|
46
|
-
interface KeyboardProviderProps {
|
|
47
|
-
children: ReactNode;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
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.
|
|
57
|
-
*/
|
|
58
|
-
export function KeyboardProvider({ children }: KeyboardProviderProps) {
|
|
59
|
-
const keyboard = useRendererKeyboard();
|
|
60
|
-
|
|
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
|
-
};
|
|
72
|
-
}, []);
|
|
73
|
-
|
|
74
|
-
const setGlobalHandler = useCallback((handler: GlobalKeyHandler) => {
|
|
75
|
-
const previous = globalHandlerRef.current;
|
|
76
|
-
globalHandlerRef.current = handler;
|
|
77
|
-
|
|
78
|
-
return () => {
|
|
79
|
-
globalHandlerRef.current = previous;
|
|
80
|
-
};
|
|
81
|
-
}, []);
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
const unregister = keyboard.setGlobalHandler((event: KeyboardEvent) => {
|
|
85
|
-
if (globalHandlerRef.current?.(event)) {
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (inputCapturedRef.current) {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
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
|
-
);
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<KeyboardContext.Provider value={value}>
|
|
117
|
-
{children}
|
|
118
|
-
</KeyboardContext.Provider>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Access the keyboard context.
|
|
124
|
-
* @throws Error if used outside of KeyboardProvider
|
|
125
|
-
*/
|
|
126
|
-
export function useKeyboardContext(): KeyboardContextValue {
|
|
127
|
-
const context = useContext(KeyboardContext);
|
|
128
|
-
if (!context) {
|
|
129
|
-
throw new Error("useKeyboardContext must be used within a KeyboardProvider");
|
|
130
|
-
}
|
|
131
|
-
return context;
|
|
132
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
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 };
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
function copyWithOsc52(text: string): boolean {
|
|
6
|
-
try {
|
|
7
|
-
const cleanText = Bun.stripANSI(text);
|
|
8
|
-
const base64 = Buffer.from(cleanText).toString("base64");
|
|
9
|
-
const osc52 = `\x1b]52;c;${base64}\x07`;
|
|
10
|
-
|
|
11
|
-
try {
|
|
12
|
-
const fd = fs.openSync("/dev/tty", "w");
|
|
13
|
-
fs.writeSync(fd, osc52);
|
|
14
|
-
fs.closeSync(fd);
|
|
15
|
-
} catch {
|
|
16
|
-
process.stdout.write(osc52);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return true;
|
|
20
|
-
} catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
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
|
-
|
|
52
|
-
export interface UseClipboardResult {
|
|
53
|
-
/** Last action message for status display */
|
|
54
|
-
lastAction: string;
|
|
55
|
-
/** Set the last action message */
|
|
56
|
-
setLastAction: (action: string) => void;
|
|
57
|
-
/** Copy and set a success message */
|
|
58
|
-
copyWithMessage: (text: string, label: string) => void;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Hook for clipboard operations using OSC 52.
|
|
63
|
-
* Works in most modern terminal emulators.
|
|
64
|
-
*/
|
|
65
|
-
export function useClipboard(): UseClipboardResult {
|
|
66
|
-
const [lastAction, setLastAction] = useState("");
|
|
67
|
-
|
|
68
|
-
const copyWithMessage = useCallback((text: string, label: string) => {
|
|
69
|
-
void (async () => {
|
|
70
|
-
const success = await copyToClipboard(text);
|
|
71
|
-
if (!success) {
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
setLastAction(`✓ ${label} copied to clipboard`);
|
|
76
|
-
setTimeout(() => setLastAction(""), 2000);
|
|
77
|
-
})();
|
|
78
|
-
}, []);
|
|
79
|
-
|
|
80
|
-
return { lastAction, setLastAction, copyWithMessage };
|
|
81
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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 };
|
|
@@ -1,54 +0,0 @@
|
|
|
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 };
|