@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,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Panel } from "../semantic/Panel.tsx";
|
|
3
|
+
import { Container } from "../semantic/Container.tsx";
|
|
4
|
+
import { Overlay } from "../semantic/Overlay.tsx";
|
|
5
|
+
|
|
6
|
+
type Dim = number | `${number}%` | "auto";
|
|
7
|
+
|
|
8
|
+
interface ModalBaseProps {
|
|
9
|
+
title?: string;
|
|
10
|
+
width?: Dim;
|
|
11
|
+
height?: Dim;
|
|
12
|
+
top?: Dim;
|
|
13
|
+
left?: Dim;
|
|
14
|
+
right?: Dim;
|
|
15
|
+
bottom?: Dim;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ModalBase({
|
|
20
|
+
title,
|
|
21
|
+
width = "80%",
|
|
22
|
+
height = "auto",
|
|
23
|
+
top = 4,
|
|
24
|
+
left = 4,
|
|
25
|
+
right,
|
|
26
|
+
bottom,
|
|
27
|
+
children,
|
|
28
|
+
}: ModalBaseProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Overlay zIndex={20} top={top} left={left} right={right} bottom={bottom} width={width} height={height}>
|
|
31
|
+
<Panel title={title} border={true} flexDirection="column" flex={1} padding={1} surface="overlay">
|
|
32
|
+
<Container flexDirection="column" gap={1} flex={1}>
|
|
33
|
+
{children}
|
|
34
|
+
</Container>
|
|
35
|
+
</Panel>
|
|
36
|
+
</Overlay>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { Theme } from "../theme.ts";
|
|
3
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";
|
|
4
8
|
|
|
5
9
|
interface ResultsPanelProps {
|
|
6
10
|
/** The result to display */
|
|
@@ -22,21 +26,18 @@ export function ResultsPanel({
|
|
|
22
26
|
focused,
|
|
23
27
|
renderResult,
|
|
24
28
|
}: ResultsPanelProps) {
|
|
25
|
-
const borderColor = focused ? Theme.borderFocused : Theme.border;
|
|
26
29
|
|
|
27
30
|
// Determine content to display
|
|
28
31
|
let content: ReactNode;
|
|
29
32
|
|
|
30
33
|
if (error) {
|
|
31
34
|
content = (
|
|
32
|
-
<
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
</
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
</text>
|
|
39
|
-
</box>
|
|
35
|
+
<Container flexDirection="column" gap={1}>
|
|
36
|
+
<Label color="error" bold>
|
|
37
|
+
Error
|
|
38
|
+
</Label>
|
|
39
|
+
<Label color="error">{error.message}</Label>
|
|
40
|
+
</Container>
|
|
40
41
|
);
|
|
41
42
|
} else if (result) {
|
|
42
43
|
if (renderResult) {
|
|
@@ -44,50 +45,40 @@ export function ResultsPanel({
|
|
|
44
45
|
|
|
45
46
|
if (typeof customContent === "string" || typeof customContent === "number" || typeof customContent === "boolean") {
|
|
46
47
|
// Wrap primitive results so the renderer gets a text node
|
|
47
|
-
content = (
|
|
48
|
-
<text fg={Theme.value}>
|
|
49
|
-
{String(customContent)}
|
|
50
|
-
</text>
|
|
51
|
-
);
|
|
48
|
+
content = <Value>{String(customContent)}</Value>;
|
|
52
49
|
} else {
|
|
53
50
|
content = customContent as ReactNode;
|
|
54
51
|
}
|
|
55
52
|
} else {
|
|
56
53
|
// Default JSON display
|
|
57
54
|
content = (
|
|
58
|
-
<
|
|
55
|
+
<Container flexDirection="column" gap={1}>
|
|
59
56
|
{result.message && (
|
|
60
|
-
<
|
|
61
|
-
{result.message}
|
|
62
|
-
</text>
|
|
57
|
+
<Label color={result.success ? "success" : "error"}>{result.message}</Label>
|
|
63
58
|
)}
|
|
64
59
|
{result.data !== undefined && result.data !== null && (
|
|
65
|
-
<
|
|
66
|
-
{JSON.stringify(result.data, null, 2)}
|
|
67
|
-
</text>
|
|
60
|
+
<Value>{JSON.stringify(result.data, null, 2)}</Value>
|
|
68
61
|
)}
|
|
69
|
-
</
|
|
62
|
+
</Container>
|
|
70
63
|
);
|
|
71
64
|
}
|
|
72
65
|
} else {
|
|
73
|
-
content =
|
|
74
|
-
<text fg={Theme.label}>No results yet...</text>
|
|
75
|
-
);
|
|
66
|
+
content = <Label color="mutedText">No results yet...</Label>;
|
|
76
67
|
}
|
|
77
68
|
|
|
78
69
|
return (
|
|
79
|
-
<
|
|
80
|
-
flexDirection="column"
|
|
81
|
-
border={true}
|
|
82
|
-
borderStyle="rounded"
|
|
83
|
-
borderColor={borderColor}
|
|
70
|
+
<Panel
|
|
84
71
|
title="Results"
|
|
72
|
+
focused={focused}
|
|
73
|
+
flex={1}
|
|
85
74
|
padding={1}
|
|
86
|
-
|
|
75
|
+
flexDirection="column"
|
|
87
76
|
>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
<ScrollView axis="vertical" flex={1} focused={focused}>
|
|
78
|
+
<Container flexDirection="column">
|
|
79
|
+
{content}
|
|
80
|
+
</Container>
|
|
81
|
+
</ScrollView>
|
|
82
|
+
</Panel>
|
|
92
83
|
);
|
|
93
84
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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";
|
|
3
5
|
|
|
4
6
|
interface StatusBarProps {
|
|
5
7
|
/** Status message to display */
|
|
@@ -15,45 +17,28 @@ interface StatusBarProps {
|
|
|
15
17
|
/**
|
|
16
18
|
* Status bar showing current status, spinner, and keyboard shortcuts.
|
|
17
19
|
*/
|
|
18
|
-
export function StatusBar({
|
|
19
|
-
status,
|
|
20
|
-
isRunning = false,
|
|
20
|
+
export function StatusBar({
|
|
21
|
+
status,
|
|
22
|
+
isRunning = false,
|
|
21
23
|
showShortcuts = true,
|
|
22
|
-
shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back"
|
|
24
|
+
shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back",
|
|
23
25
|
}: StatusBarProps) {
|
|
24
|
-
const { frame } = useSpinner(isRunning);
|
|
25
|
-
const spinner = isRunning ? `${frame} ` : "";
|
|
26
|
-
|
|
27
26
|
return (
|
|
28
|
-
<
|
|
29
|
-
flexDirection="
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<text fg={isRunning ? "#4ade80" : Theme.statusText}>
|
|
45
|
-
{isRunning ? <strong>{spinner}{status}</strong> : <>{spinner}{status}</>}
|
|
46
|
-
</text>
|
|
47
|
-
</box>
|
|
48
|
-
|
|
49
|
-
{/* Keyboard shortcuts */}
|
|
50
|
-
{showShortcuts && (
|
|
51
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
52
|
-
<text fg={Theme.label}>
|
|
53
|
-
{shortcuts}
|
|
54
|
-
</text>
|
|
55
|
-
</box>
|
|
56
|
-
)}
|
|
57
|
-
</box>
|
|
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>
|
|
58
43
|
);
|
|
59
44
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
}
|
|
@@ -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 {
|