@pablozaiden/terminatui 0.3.0-beta-1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/package.json +10 -3
  2. package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
  3. package/src/__tests__/configOnChange.test.ts +63 -0
  4. package/src/__tests__/schemaToFields.test.ts +0 -4
  5. package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
  6. package/src/builtins/version.ts +1 -1
  7. package/src/index.ts +22 -0
  8. package/src/tui/TuiApplication.tsx +0 -4
  9. package/src/tui/TuiRoot.tsx +58 -102
  10. package/src/tui/actions.ts +4 -0
  11. package/src/tui/adapters/ink/InkRenderer.tsx +191 -41
  12. package/src/tui/adapters/ink/SemanticInkRenderer.tsx +210 -0
  13. package/src/tui/adapters/ink/components/Button.tsx +10 -2
  14. package/src/tui/adapters/ink/components/Overlay.tsx +8 -2
  15. package/src/tui/adapters/ink/components/Panel.tsx +26 -5
  16. package/src/tui/adapters/ink/components/ScrollView.tsx +44 -3
  17. package/src/tui/adapters/ink/components/Spinner.tsx +8 -2
  18. package/src/tui/adapters/ink/keyboard.ts +0 -3
  19. package/src/tui/adapters/ink/ui/CommandSelector.tsx +56 -0
  20. package/src/tui/adapters/ink/ui/ConfigForm.tsx +77 -0
  21. package/src/tui/adapters/ink/ui/Header.tsx +25 -0
  22. package/src/tui/adapters/ink/ui/JsonHighlight.tsx +21 -0
  23. package/src/tui/adapters/ink/ui/ResultsPanel.tsx +57 -0
  24. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +190 -39
  25. package/src/tui/adapters/opentui/SemanticOpenTuiRenderer.tsx +192 -0
  26. package/src/tui/adapters/opentui/components/Label.tsx +2 -2
  27. package/src/tui/adapters/opentui/components/Overlay.tsx +12 -3
  28. package/src/tui/adapters/opentui/components/Panel.tsx +11 -1
  29. package/src/tui/adapters/opentui/components/ScrollView.tsx +1 -8
  30. package/src/tui/adapters/opentui/components/Spinner.tsx +1 -1
  31. package/src/tui/adapters/opentui/keyboard.ts +0 -3
  32. package/src/tui/adapters/opentui/ui/CommandSelector.tsx +55 -0
  33. package/src/tui/adapters/opentui/ui/ConfigForm.tsx +74 -0
  34. package/src/tui/adapters/opentui/ui/Header.tsx +24 -0
  35. package/src/tui/adapters/opentui/ui/JsonHighlight.tsx +20 -0
  36. package/src/tui/adapters/opentui/ui/LogsPanel.tsx +44 -0
  37. package/src/tui/adapters/opentui/ui/ResultsPanel.tsx +62 -0
  38. package/src/tui/adapters/shared/TerminalClipboard.ts +65 -0
  39. package/src/tui/adapters/{opentui/hooks → shared}/useSpinner.ts +5 -1
  40. package/src/tui/adapters/types.ts +25 -45
  41. package/src/tui/components/JsonHighlight.tsx +41 -111
  42. package/src/tui/context/ActionContext.tsx +51 -0
  43. package/src/tui/context/ExecutorContext.tsx +7 -1
  44. package/src/tui/context/NavigationContext.tsx +20 -4
  45. package/src/tui/controllers/CommandBrowserController.tsx +100 -0
  46. package/src/tui/controllers/ConfigController.tsx +183 -0
  47. package/src/tui/controllers/EditorController.tsx +169 -0
  48. package/src/tui/controllers/LogsController.tsx +48 -0
  49. package/src/tui/controllers/OutcomeController.tsx +110 -0
  50. package/src/tui/driver/TuiDriver.tsx +148 -0
  51. package/src/tui/driver/context/TuiDriverContext.tsx +44 -0
  52. package/src/tui/driver/types.ts +72 -0
  53. package/src/tui/semantic/AppShell.tsx +30 -0
  54. package/src/tui/semantic/CommandBrowserScreen.tsx +16 -0
  55. package/src/tui/semantic/ConfigScreen.tsx +23 -0
  56. package/src/tui/semantic/EditorScreen.tsx +20 -0
  57. package/src/tui/semantic/LogsScreen.tsx +9 -0
  58. package/src/tui/semantic/RunningScreen.tsx +17 -0
  59. package/src/tui/semantic/layoutTypes.ts +72 -0
  60. package/src/tui/semantic/render.tsx +44 -0
  61. package/src/tui/semantic/types.ts +31 -98
  62. package/src/tui/utils/jsonTokenizer.ts +98 -0
  63. package/src/tui/utils/schemaToFields.ts +1 -25
  64. package/.devcontainer/devcontainer.json +0 -19
  65. package/.devcontainer/install-prerequisites.sh +0 -49
  66. package/.github/workflows/copilot-setup-steps.yml +0 -32
  67. package/.github/workflows/pull-request.yml +0 -27
  68. package/.github/workflows/release-npm-package.yml +0 -81
  69. package/AGENTS.md +0 -43
  70. package/CLAUDE.md +0 -1
  71. package/bun.lock +0 -321
  72. package/examples/tui-app/commands/config/app/get.ts +0 -62
  73. package/examples/tui-app/commands/config/app/index.ts +0 -23
  74. package/examples/tui-app/commands/config/app/set.ts +0 -96
  75. package/examples/tui-app/commands/config/index.ts +0 -28
  76. package/examples/tui-app/commands/config/user/get.ts +0 -61
  77. package/examples/tui-app/commands/config/user/index.ts +0 -23
  78. package/examples/tui-app/commands/config/user/set.ts +0 -57
  79. package/examples/tui-app/commands/greet.ts +0 -78
  80. package/examples/tui-app/commands/math.ts +0 -111
  81. package/examples/tui-app/commands/status.ts +0 -86
  82. package/examples/tui-app/index.ts +0 -38
  83. package/guides/01-hello-world.md +0 -101
  84. package/guides/02-adding-options.md +0 -103
  85. package/guides/03-multiple-commands.md +0 -161
  86. package/guides/04-subcommands.md +0 -206
  87. package/guides/05-interactive-tui.md +0 -209
  88. package/guides/06-config-validation.md +0 -256
  89. package/guides/07-async-cancellation.md +0 -334
  90. package/guides/08-complete-application.md +0 -507
  91. package/guides/README.md +0 -78
  92. package/src/tui/adapters/ink/components/Code.tsx +0 -6
  93. package/src/tui/adapters/ink/components/Container.tsx +0 -5
  94. package/src/tui/adapters/ink/components/Spacer.tsx +0 -15
  95. package/src/tui/adapters/ink/components/Value.tsx +0 -7
  96. package/src/tui/adapters/opentui/components/Code.tsx +0 -12
  97. package/src/tui/adapters/opentui/components/Container.tsx +0 -56
  98. package/src/tui/adapters/opentui/components/Spacer.tsx +0 -5
  99. package/src/tui/adapters/opentui/components/Value.tsx +0 -13
  100. package/src/tui/components/ActionButton.tsx +0 -0
  101. package/src/tui/components/CommandSelector.tsx +0 -119
  102. package/src/tui/components/ConfigForm.tsx +0 -174
  103. package/src/tui/components/FieldRow.tsx +0 -0
  104. package/src/tui/components/Header.tsx +0 -32
  105. package/src/tui/components/ModalBase.tsx +0 -38
  106. package/src/tui/components/ResultsPanel.tsx +0 -84
  107. package/src/tui/components/StatusBar.tsx +0 -44
  108. package/src/tui/components/logColors.ts +0 -12
  109. package/src/tui/components/types.ts +0 -30
  110. package/src/tui/context/ClipboardContext.tsx +0 -87
  111. package/src/tui/context/KeyboardContext.tsx +0 -132
  112. package/src/tui/hooks/useActiveKeyHandler.ts +0 -75
  113. package/src/tui/hooks/useClipboard.ts +0 -81
  114. package/src/tui/hooks/useClipboardProvider.ts +0 -42
  115. package/src/tui/hooks/useGlobalKeyHandler.ts +0 -54
  116. package/src/tui/modals/CliModal.tsx +0 -82
  117. package/src/tui/modals/EditorModal.tsx +0 -207
  118. package/src/tui/modals/LogsModal.tsx +0 -98
  119. package/src/tui/registry.ts +0 -102
  120. package/src/tui/screens/CommandSelectScreen.tsx +0 -162
  121. package/src/tui/screens/ConfigScreen.tsx +0 -160
  122. package/src/tui/screens/ErrorScreen.tsx +0 -58
  123. package/src/tui/screens/ResultsScreen.tsx +0 -60
  124. package/src/tui/screens/RunningScreen.tsx +0 -72
  125. package/src/tui/screens/ScreenBase.ts +0 -6
  126. package/src/tui/semantic/Button.tsx +0 -7
  127. package/src/tui/semantic/Code.tsx +0 -7
  128. package/src/tui/semantic/CodeHighlight.tsx +0 -7
  129. package/src/tui/semantic/Container.tsx +0 -7
  130. package/src/tui/semantic/Field.tsx +0 -7
  131. package/src/tui/semantic/Label.tsx +0 -7
  132. package/src/tui/semantic/MenuButton.tsx +0 -7
  133. package/src/tui/semantic/MenuItem.tsx +0 -7
  134. package/src/tui/semantic/Overlay.tsx +0 -7
  135. package/src/tui/semantic/Panel.tsx +0 -7
  136. package/src/tui/semantic/ScrollView.tsx +0 -9
  137. package/src/tui/semantic/Select.tsx +0 -7
  138. package/src/tui/semantic/Spacer.tsx +0 -7
  139. package/src/tui/semantic/Spinner.tsx +0 -7
  140. package/src/tui/semantic/TextInput.tsx +0 -7
  141. package/src/tui/semantic/Value.tsx +0 -7
  142. package/tsconfig.json +0 -25
@@ -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 };
@@ -1,82 +0,0 @@
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
- }
@@ -1,207 +0,0 @@
1
- import { useState, useEffect } from "react";
2
- import type { FieldConfig } from "../components/types.ts";
3
- import { ModalBase } from "../components/ModalBase.tsx";
4
- import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
5
- import { Select } from "../semantic/Select.tsx";
6
- import { TextInput } from "../semantic/TextInput.tsx";
7
- import { Label } from "../semantic/Label.tsx";
8
- import type { ModalComponent, ModalDefinition } from "../registry.ts";
9
- import { useKeyboardContext } from "../context/KeyboardContext.tsx";
10
-
11
- export interface EditorModalParams {
12
- fieldKey: string;
13
- currentValue: unknown;
14
- fieldConfigs: FieldConfig[];
15
- onSubmit: (value: unknown) => void;
16
- onCancel: () => void;
17
- }
18
-
19
- export class EditorModal implements ModalDefinition<EditorModalParams> {
20
- static readonly Id = "property-editor";
21
-
22
- getId(): string {
23
- return EditorModal.Id;
24
- }
25
-
26
- component(): ModalComponent<EditorModalParams> {
27
- return function EditorModalComponentWrapper({ params, onClose }: { params: EditorModalParams; onClose: () => void; }) {
28
- return (
29
- <EditorModalView
30
- fieldKey={params.fieldKey}
31
- currentValue={params.currentValue}
32
- visible={true}
33
- onSubmit={(value) => {
34
- params.onSubmit?.(value);
35
- }}
36
- onCancel={() => {
37
- params.onCancel?.();
38
- onClose();
39
- }}
40
- fieldConfigs={params.fieldConfigs}
41
- />
42
- );
43
- };
44
- }
45
- }
46
-
47
- interface EditorModalViewProps {
48
- /** Whether the modal is visible */
49
- visible: boolean;
50
- }
51
-
52
- /**
53
- * Modal for editing field values.
54
- * Supports text, number, enum, and boolean types.
55
- *
56
- * Note: This modal uses native OpenTUI input/select components that handle
57
- * keyboard events internally. The modal registers as the active handler to
58
- * block the underlying screen from receiving events, even though most key
59
- * handling is done by the native components.
60
- */
61
- interface EditorModalViewProps extends EditorModalParams {
62
- /** Whether the modal is visible */
63
- visible: boolean;
64
- }
65
-
66
- function EditorModalView({
67
- fieldKey,
68
- currentValue,
69
- visible,
70
- onSubmit,
71
- onCancel,
72
- fieldConfigs,
73
- }: EditorModalViewProps) {
74
- const { setInputCaptured } = useKeyboardContext();
75
- const [inputValue, setInputValue] = useState("");
76
- const [selectIndex, setSelectIndex] = useState(0);
77
-
78
- // Reset state when field changes
79
- useEffect(() => {
80
- if (fieldKey && visible) {
81
- setInputValue(String(currentValue ?? ""));
82
-
83
- // For enums/booleans, find current index
84
- const fieldConfig = fieldConfigs.find((f) => f.key === fieldKey);
85
- if (fieldConfig?.type === "boolean") {
86
- setSelectIndex(currentValue ? 1 : 0);
87
- } else if (fieldConfig?.options) {
88
- const idx = fieldConfig.options.findIndex((o) => o.value === currentValue);
89
- setSelectIndex(idx >= 0 ? idx : 0);
90
- }
91
- }
92
- }, [fieldKey, currentValue, visible, fieldConfigs]);
93
-
94
- // While the editor is open, avoid extra per-keystroke work by preventing the
95
- // underlying screen from receiving key events.
96
- useEffect(() => {
97
- if (!visible || !fieldKey) {
98
- return;
99
- }
100
-
101
- setInputCaptured(true);
102
- return () => {
103
- setInputCaptured(false);
104
- };
105
- }, [visible, fieldKey, setInputCaptured]);
106
-
107
- // Register as active handler to block underlying screen from receiving events.
108
- // The native input/select components handle Enter internally via onSubmit/onSelect,
109
- // so we don't need to handle it here.
110
- useActiveKeyHandler(
111
- (event) => {
112
- if (event.name === "return" || event.name === "enter") {
113
- return true;
114
- }
115
-
116
- if (event.name === "escape") {
117
- onCancel();
118
- return true;
119
- }
120
-
121
- return false;
122
- },
123
- { enabled: visible && Boolean(fieldKey) }
124
- );
125
-
126
- if (!visible || !fieldKey) {
127
- return null;
128
- }
129
-
130
- const fieldConfig = fieldConfigs.find((f) => f.key === fieldKey);
131
- if (!fieldConfig) {
132
- return null;
133
- }
134
-
135
- const isNumber = fieldConfig.type === "number";
136
-
137
- const handleInputSubmit = (value: string) => {
138
- if (isNumber) {
139
- onSubmit(parseInt(value.replace(/[^0-9-]/g, ""), 10) || 0);
140
- onCancel();
141
- return;
142
- }
143
-
144
- onSubmit(value);
145
- onCancel();
146
- };
147
-
148
- const selectOptions =
149
- fieldConfig.type === "boolean"
150
- ? [
151
- { label: "False", value: "false" },
152
- { label: "True", value: "true" },
153
- ]
154
- : fieldConfig.type === "enum"
155
- ? (fieldConfig.options ?? []).map((o) => ({
156
- label: o.name,
157
- value: String(o.value),
158
- }))
159
- : null;
160
-
161
- const usesSelect = selectOptions !== null;
162
-
163
- const handleSelectSubmit = () => {
164
- const selected = selectOptions?.[selectIndex];
165
- if (!selected) {
166
- return;
167
- }
168
-
169
- if (fieldConfig.type === "boolean") {
170
- onSubmit(selected.value === "true");
171
- onCancel();
172
- return;
173
- }
174
-
175
- onSubmit(selected.value);
176
- onCancel();
177
- };
178
-
179
- return (
180
- <ModalBase title={`Edit: ${fieldConfig.label}`} width="60%" height={12} top={4} left={6}>
181
- {usesSelect && selectOptions && (
182
- <Select
183
- options={selectOptions}
184
- value={selectOptions[selectIndex]?.value ?? selectOptions[0]?.value ?? ""}
185
- focused={true}
186
- onChange={(next) => {
187
- const idx = selectOptions.findIndex((o) => o.value === next);
188
- setSelectIndex(idx >= 0 ? idx : 0);
189
- }}
190
- onSubmit={handleSelectSubmit}
191
- />
192
- )}
193
-
194
- {!usesSelect && (
195
- <TextInput
196
- value={inputValue}
197
- placeholder={fieldConfig.placeholder ?? `Enter ${fieldConfig.label.toLowerCase()}...`}
198
- focused={true}
199
- onChange={(value) => setInputValue(value)}
200
- onSubmit={() => handleInputSubmit(inputValue)}
201
- />
202
- )}
203
-
204
- <Label color="mutedText">Enter to save, Esc to cancel</Label>
205
- </ModalBase>
206
- );
207
- }