@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.
Files changed (175) hide show
  1. package/AGENTS.md +43 -0
  2. package/CLAUDE.md +1 -0
  3. package/README.md +64 -43
  4. package/bun.lock +85 -0
  5. package/examples/tui-app/commands/config/app/get.ts +62 -0
  6. package/examples/tui-app/commands/config/app/index.ts +23 -0
  7. package/examples/tui-app/commands/config/app/set.ts +96 -0
  8. package/examples/tui-app/commands/config/index.ts +28 -0
  9. package/examples/tui-app/commands/config/user/get.ts +61 -0
  10. package/examples/tui-app/commands/config/user/index.ts +23 -0
  11. package/examples/tui-app/commands/config/user/set.ts +57 -0
  12. package/examples/tui-app/commands/greet.ts +14 -11
  13. package/examples/tui-app/commands/math.ts +6 -9
  14. package/examples/tui-app/commands/status.ts +24 -13
  15. package/examples/tui-app/index.ts +7 -3
  16. package/guides/01-hello-world.md +7 -2
  17. package/guides/02-adding-options.md +2 -2
  18. package/guides/03-multiple-commands.md +6 -8
  19. package/guides/04-subcommands.md +8 -8
  20. package/guides/05-interactive-tui.md +45 -30
  21. package/guides/06-config-validation.md +4 -12
  22. package/guides/07-async-cancellation.md +15 -69
  23. package/guides/08-complete-application.md +13 -179
  24. package/guides/README.md +7 -3
  25. package/package.json +4 -8
  26. package/src/__tests__/application.test.ts +87 -68
  27. package/src/__tests__/buildCliCommand.test.ts +99 -119
  28. package/src/__tests__/builtins.test.ts +27 -75
  29. package/src/__tests__/command.test.ts +100 -131
  30. package/src/__tests__/context.test.ts +1 -26
  31. package/src/__tests__/helpCore.test.ts +227 -0
  32. package/src/__tests__/parser.test.ts +98 -244
  33. package/src/__tests__/registry.test.ts +33 -160
  34. package/src/__tests__/schemaToFields.test.ts +75 -158
  35. package/src/builtins/help.ts +19 -4
  36. package/src/builtins/settings.ts +18 -32
  37. package/src/builtins/version.ts +4 -4
  38. package/src/cli/output/colors.ts +1 -1
  39. package/src/cli/parser.ts +26 -95
  40. package/src/core/application.ts +192 -110
  41. package/src/core/command.ts +26 -9
  42. package/src/core/context.ts +31 -20
  43. package/src/core/help.ts +24 -18
  44. package/src/core/knownCommands.ts +13 -0
  45. package/src/core/logger.ts +39 -42
  46. package/src/core/registry.ts +5 -12
  47. package/src/tui/TuiApplication.tsx +63 -120
  48. package/src/tui/TuiRoot.tsx +135 -0
  49. package/src/tui/adapters/factory.ts +19 -0
  50. package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
  51. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  52. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  53. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  54. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  55. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  56. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  57. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  58. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  59. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  60. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  61. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  62. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  63. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  64. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  65. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  66. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  67. package/src/tui/adapters/ink/keyboard.ts +97 -0
  68. package/src/tui/adapters/ink/utils.ts +16 -0
  69. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
  70. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  71. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  72. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  73. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  74. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  75. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  76. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  77. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  78. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  79. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  80. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  81. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  82. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  83. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  84. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  85. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  86. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  87. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  88. package/src/tui/adapters/types.ts +70 -0
  89. package/src/tui/components/ActionButton.tsx +0 -36
  90. package/src/tui/components/CommandSelector.tsx +52 -92
  91. package/src/tui/components/ConfigForm.tsx +68 -42
  92. package/src/tui/components/FieldRow.tsx +0 -30
  93. package/src/tui/components/Header.tsx +14 -13
  94. package/src/tui/components/JsonHighlight.tsx +10 -17
  95. package/src/tui/components/ModalBase.tsx +38 -0
  96. package/src/tui/components/ResultsPanel.tsx +27 -36
  97. package/src/tui/components/StatusBar.tsx +24 -39
  98. package/src/tui/components/logColors.ts +12 -0
  99. package/src/tui/context/ClipboardContext.tsx +87 -0
  100. package/src/tui/context/ExecutorContext.tsx +139 -0
  101. package/src/tui/context/KeyboardContext.tsx +85 -71
  102. package/src/tui/context/LogsContext.tsx +35 -0
  103. package/src/tui/context/NavigationContext.tsx +194 -0
  104. package/src/tui/context/RendererContext.tsx +20 -0
  105. package/src/tui/context/TuiAppContext.tsx +58 -0
  106. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  107. package/src/tui/hooks/useBackHandler.ts +34 -0
  108. package/src/tui/hooks/useClipboard.ts +40 -25
  109. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  110. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  111. package/src/tui/modals/CliModal.tsx +82 -0
  112. package/src/tui/modals/EditorModal.tsx +207 -0
  113. package/src/tui/modals/LogsModal.tsx +98 -0
  114. package/src/tui/registry.ts +102 -0
  115. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  116. package/src/tui/screens/ConfigScreen.tsx +160 -0
  117. package/src/tui/screens/ErrorScreen.tsx +58 -0
  118. package/src/tui/screens/ResultsScreen.tsx +60 -0
  119. package/src/tui/screens/RunningScreen.tsx +72 -0
  120. package/src/tui/screens/ScreenBase.ts +6 -0
  121. package/src/tui/semantic/Button.tsx +7 -0
  122. package/src/tui/semantic/Code.tsx +7 -0
  123. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  124. package/src/tui/semantic/Container.tsx +7 -0
  125. package/src/tui/semantic/Field.tsx +7 -0
  126. package/src/tui/semantic/Label.tsx +7 -0
  127. package/src/tui/semantic/MenuButton.tsx +7 -0
  128. package/src/tui/semantic/MenuItem.tsx +7 -0
  129. package/src/tui/semantic/Overlay.tsx +7 -0
  130. package/src/tui/semantic/Panel.tsx +7 -0
  131. package/src/tui/semantic/ScrollView.tsx +9 -0
  132. package/src/tui/semantic/Select.tsx +7 -0
  133. package/src/tui/semantic/Spacer.tsx +7 -0
  134. package/src/tui/semantic/Spinner.tsx +7 -0
  135. package/src/tui/semantic/TextInput.tsx +7 -0
  136. package/src/tui/semantic/Value.tsx +7 -0
  137. package/src/tui/semantic/types.ts +195 -0
  138. package/src/tui/theme.ts +25 -14
  139. package/src/tui/utils/buildCliCommand.ts +1 -0
  140. package/src/tui/utils/getEnumKeys.ts +3 -0
  141. package/src/tui/utils/parameterPersistence.ts +1 -0
  142. package/src/types/command.ts +0 -60
  143. package/examples/tui-app/commands/index.ts +0 -3
  144. package/src/__tests__/colors.test.ts +0 -127
  145. package/src/__tests__/commandClass.test.ts +0 -130
  146. package/src/__tests__/help.test.ts +0 -412
  147. package/src/__tests__/registryNew.test.ts +0 -160
  148. package/src/__tests__/table.test.ts +0 -146
  149. package/src/__tests__/tui.test.ts +0 -26
  150. package/src/builtins/index.ts +0 -4
  151. package/src/cli/help.ts +0 -174
  152. package/src/cli/index.ts +0 -3
  153. package/src/cli/output/index.ts +0 -2
  154. package/src/cli/output/table.ts +0 -141
  155. package/src/commands/help.ts +0 -50
  156. package/src/commands/index.ts +0 -1
  157. package/src/components/index.ts +0 -147
  158. package/src/core/index.ts +0 -15
  159. package/src/hooks/index.ts +0 -131
  160. package/src/index.ts +0 -137
  161. package/src/registry/commandRegistry.ts +0 -77
  162. package/src/registry/index.ts +0 -1
  163. package/src/tui/TuiApp.tsx +0 -582
  164. package/src/tui/app.ts +0 -29
  165. package/src/tui/components/CliModal.tsx +0 -81
  166. package/src/tui/components/EditorModal.tsx +0 -177
  167. package/src/tui/components/LogsPanel.tsx +0 -86
  168. package/src/tui/components/index.ts +0 -13
  169. package/src/tui/context/index.ts +0 -7
  170. package/src/tui/hooks/index.ts +0 -35
  171. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  172. package/src/tui/hooks/useLogStream.ts +0 -96
  173. package/src/tui/index.ts +0 -65
  174. package/src/tui/utils/index.ts +0 -13
  175. 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
- <box flexDirection="column" gap={1}>
33
- <text fg={Theme.error}>
34
- <strong>Error</strong>
35
- </text>
36
- <text fg={Theme.error}>
37
- {error.message}
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
- <box flexDirection="column" gap={1}>
55
+ <Container flexDirection="column" gap={1}>
59
56
  {result.message && (
60
- <text fg={result.success ? Theme.success : Theme.error}>
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
- <text fg={Theme.value}>
66
- {JSON.stringify(result.data, null, 2)}
67
- </text>
60
+ <Value>{JSON.stringify(result.data, null, 2)}</Value>
68
61
  )}
69
- </box>
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
- <box
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
- flexGrow={1}
75
+ flexDirection="column"
87
76
  >
88
- <scrollbox scrollY={true} flexGrow={1} focused={focused}>
89
- {content}
90
- </scrollbox>
91
- </box>
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 { Theme } from "../theme.ts";
2
- import { useSpinner } from "../hooks/useSpinner.ts";
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
- <box
29
- flexDirection="column"
30
- gap={0}
31
- border={true}
32
- borderStyle="rounded"
33
- borderColor={isRunning ? "#4ade80" : Theme.border}
34
- flexShrink={0}
35
- >
36
- {/* Main status with spinner */}
37
- <box
38
- flexDirection="row"
39
- justifyContent="space-between"
40
- backgroundColor={isRunning ? "#1a1a2e" : undefined}
41
- paddingLeft={1}
42
- paddingRight={1}
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 { useKeyboard } from "@opentui/react";
9
- import type { KeyEvent } from "@opentui/core";
10
+ import type { KeyboardEvent, KeyHandler } from "../adapters/types.ts";
11
+ import { useRenderer } from "./RendererContext.tsx";
10
12
 
11
- /**
12
- * Priority levels for keyboard event handlers.
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
- interface RegisteredHandler {
41
- id: string;
42
- handler: KeyboardHandler;
43
- priority: KeyboardPriority;
44
- }
18
+ export type GlobalKeyHandler = (event: KeyboardEvent) => boolean;
45
19
 
46
20
  interface KeyboardContextValue {
47
- register: (id: string, handler: KeyboardHandler, priority: KeyboardPriority) => void;
48
- unregister: (id: string) => void;
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 all keyboard handlers via a single useKeyboard call.
59
- * Handlers are invoked in descending priority order (highest first).
60
- * Propagation stops when a handler calls `stopPropagation()`.
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 handlersRef = useRef<RegisteredHandler[]>([]);
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 unregister = useCallback((id: string) => {
78
- handlersRef.current = handlersRef.current.filter((h) => h.id !== id);
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
- // Single useKeyboard call that dispatches to all registered handlers
82
- useKeyboard((key: KeyEvent) => {
83
- // Create our wrapper event with custom stop propagation
84
- const event: KeyboardEvent = {
85
- key,
86
- stopped: false,
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
- for (const { handler } of handlersRef.current) {
93
- // Stop if our propagation was stopped or if preventDefault was called
94
- if (event.stopped || key.defaultPrevented) {
95
- break;
89
+ if (inputCapturedRef.current) {
90
+ return false;
96
91
  }
97
- handler(event);
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={{ register, unregister }}>
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 for handler registration.
123
+ * Access the keyboard context.
110
124
  * @throws Error if used outside of KeyboardProvider
111
125
  */
112
126
  export function useKeyboardContext(): KeyboardContextValue {