@pablozaiden/terminatui 0.2.0 → 0.3.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 (181) hide show
  1. package/README.md +64 -43
  2. package/package.json +11 -8
  3. package/src/__tests__/application.test.ts +87 -68
  4. package/src/__tests__/buildCliCommand.test.ts +99 -119
  5. package/src/__tests__/builtins.test.ts +27 -75
  6. package/src/__tests__/command.test.ts +100 -131
  7. package/src/__tests__/configOnChange.test.ts +63 -0
  8. package/src/__tests__/context.test.ts +1 -26
  9. package/src/__tests__/helpCore.test.ts +227 -0
  10. package/src/__tests__/parser.test.ts +98 -244
  11. package/src/__tests__/registry.test.ts +33 -160
  12. package/src/__tests__/schemaToFields.test.ts +75 -158
  13. package/src/builtins/help.ts +12 -4
  14. package/src/builtins/settings.ts +18 -32
  15. package/src/builtins/version.ts +3 -3
  16. package/src/cli/output/colors.ts +1 -1
  17. package/src/cli/parser.ts +26 -95
  18. package/src/core/application.ts +192 -110
  19. package/src/core/command.ts +26 -9
  20. package/src/core/context.ts +31 -20
  21. package/src/core/help.ts +24 -18
  22. package/src/core/knownCommands.ts +13 -0
  23. package/src/core/logger.ts +39 -42
  24. package/src/core/registry.ts +5 -12
  25. package/src/index.ts +22 -137
  26. package/src/tui/TuiApplication.tsx +63 -120
  27. package/src/tui/TuiRoot.tsx +135 -0
  28. package/src/tui/adapters/factory.ts +19 -0
  29. package/src/tui/adapters/ink/InkRenderer.tsx +139 -0
  30. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  31. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  32. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  33. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  34. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  35. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  36. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  37. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  38. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  39. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  40. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  41. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  42. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  43. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  44. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  45. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  46. package/src/tui/adapters/ink/keyboard.ts +97 -0
  47. package/src/tui/adapters/ink/utils.ts +16 -0
  48. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +119 -0
  49. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  50. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  51. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  52. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  53. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  54. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  55. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  56. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  57. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  58. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  59. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  60. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  61. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  62. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  63. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  64. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  65. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  66. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  67. package/src/tui/adapters/types.ts +71 -0
  68. package/src/tui/components/ActionButton.tsx +0 -36
  69. package/src/tui/components/CommandSelector.tsx +45 -92
  70. package/src/tui/components/ConfigForm.tsx +68 -42
  71. package/src/tui/components/FieldRow.tsx +0 -30
  72. package/src/tui/components/Header.tsx +14 -13
  73. package/src/tui/components/JsonHighlight.tsx +10 -17
  74. package/src/tui/components/ModalBase.tsx +38 -0
  75. package/src/tui/components/ResultsPanel.tsx +27 -36
  76. package/src/tui/components/StatusBar.tsx +24 -39
  77. package/src/tui/components/logColors.ts +12 -0
  78. package/src/tui/context/ClipboardContext.tsx +87 -0
  79. package/src/tui/context/ExecutorContext.tsx +139 -0
  80. package/src/tui/context/KeyboardContext.tsx +85 -71
  81. package/src/tui/context/LogsContext.tsx +35 -0
  82. package/src/tui/context/NavigationContext.tsx +194 -0
  83. package/src/tui/context/RendererContext.tsx +20 -0
  84. package/src/tui/context/TuiAppContext.tsx +58 -0
  85. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  86. package/src/tui/hooks/useBackHandler.ts +34 -0
  87. package/src/tui/hooks/useClipboard.ts +40 -25
  88. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  89. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  90. package/src/tui/modals/CliModal.tsx +82 -0
  91. package/src/tui/modals/EditorModal.tsx +207 -0
  92. package/src/tui/modals/LogsModal.tsx +98 -0
  93. package/src/tui/registry.ts +102 -0
  94. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  95. package/src/tui/screens/ConfigScreen.tsx +165 -0
  96. package/src/tui/screens/ErrorScreen.tsx +58 -0
  97. package/src/tui/screens/ResultsScreen.tsx +68 -0
  98. package/src/tui/screens/RunningScreen.tsx +72 -0
  99. package/src/tui/screens/ScreenBase.ts +6 -0
  100. package/src/tui/semantic/Button.tsx +7 -0
  101. package/src/tui/semantic/Code.tsx +7 -0
  102. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  103. package/src/tui/semantic/Container.tsx +7 -0
  104. package/src/tui/semantic/Field.tsx +7 -0
  105. package/src/tui/semantic/Label.tsx +7 -0
  106. package/src/tui/semantic/MenuButton.tsx +7 -0
  107. package/src/tui/semantic/MenuItem.tsx +7 -0
  108. package/src/tui/semantic/Overlay.tsx +7 -0
  109. package/src/tui/semantic/Panel.tsx +7 -0
  110. package/src/tui/semantic/ScrollView.tsx +9 -0
  111. package/src/tui/semantic/Select.tsx +7 -0
  112. package/src/tui/semantic/Spacer.tsx +7 -0
  113. package/src/tui/semantic/Spinner.tsx +7 -0
  114. package/src/tui/semantic/TextInput.tsx +7 -0
  115. package/src/tui/semantic/Value.tsx +7 -0
  116. package/src/tui/semantic/types.ts +195 -0
  117. package/src/tui/theme.ts +25 -14
  118. package/src/tui/utils/buildCliCommand.ts +1 -0
  119. package/src/tui/utils/getEnumKeys.ts +3 -0
  120. package/src/tui/utils/parameterPersistence.ts +1 -0
  121. package/src/types/command.ts +0 -60
  122. package/.devcontainer/devcontainer.json +0 -19
  123. package/.devcontainer/install-prerequisites.sh +0 -49
  124. package/.github/workflows/copilot-setup-steps.yml +0 -32
  125. package/.github/workflows/pull-request.yml +0 -27
  126. package/.github/workflows/release-npm-package.yml +0 -81
  127. package/AGENTS.md +0 -31
  128. package/bun.lock +0 -236
  129. package/examples/tui-app/commands/config/app/get.ts +0 -66
  130. package/examples/tui-app/commands/config/app/index.ts +0 -27
  131. package/examples/tui-app/commands/config/app/set.ts +0 -86
  132. package/examples/tui-app/commands/config/index.ts +0 -32
  133. package/examples/tui-app/commands/config/user/get.ts +0 -65
  134. package/examples/tui-app/commands/config/user/index.ts +0 -27
  135. package/examples/tui-app/commands/config/user/set.ts +0 -61
  136. package/examples/tui-app/commands/greet.ts +0 -76
  137. package/examples/tui-app/commands/index.ts +0 -4
  138. package/examples/tui-app/commands/math.ts +0 -115
  139. package/examples/tui-app/commands/status.ts +0 -77
  140. package/examples/tui-app/index.ts +0 -35
  141. package/guides/01-hello-world.md +0 -96
  142. package/guides/02-adding-options.md +0 -103
  143. package/guides/03-multiple-commands.md +0 -163
  144. package/guides/04-subcommands.md +0 -206
  145. package/guides/05-interactive-tui.md +0 -194
  146. package/guides/06-config-validation.md +0 -264
  147. package/guides/07-async-cancellation.md +0 -336
  148. package/guides/08-complete-application.md +0 -537
  149. package/guides/README.md +0 -74
  150. package/src/__tests__/colors.test.ts +0 -127
  151. package/src/__tests__/commandClass.test.ts +0 -130
  152. package/src/__tests__/help.test.ts +0 -412
  153. package/src/__tests__/registryNew.test.ts +0 -160
  154. package/src/__tests__/table.test.ts +0 -146
  155. package/src/__tests__/tui.test.ts +0 -26
  156. package/src/builtins/index.ts +0 -4
  157. package/src/cli/help.ts +0 -174
  158. package/src/cli/index.ts +0 -3
  159. package/src/cli/output/index.ts +0 -2
  160. package/src/cli/output/table.ts +0 -141
  161. package/src/commands/help.ts +0 -50
  162. package/src/commands/index.ts +0 -1
  163. package/src/components/index.ts +0 -147
  164. package/src/core/index.ts +0 -15
  165. package/src/hooks/index.ts +0 -131
  166. package/src/registry/commandRegistry.ts +0 -77
  167. package/src/registry/index.ts +0 -1
  168. package/src/tui/TuiApp.tsx +0 -619
  169. package/src/tui/app.ts +0 -29
  170. package/src/tui/components/CliModal.tsx +0 -81
  171. package/src/tui/components/EditorModal.tsx +0 -177
  172. package/src/tui/components/LogsPanel.tsx +0 -86
  173. package/src/tui/components/index.ts +0 -13
  174. package/src/tui/context/index.ts +0 -7
  175. package/src/tui/hooks/index.ts +0 -35
  176. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  177. package/src/tui/hooks/useLogStream.ts +0 -96
  178. package/src/tui/index.ts +0 -65
  179. package/src/tui/utils/index.ts +0 -13
  180. package/src/types/index.ts +0 -1
  181. package/tsconfig.json +0 -25
@@ -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 {
@@ -0,0 +1,35 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { AppContext } from "../../core/context.ts";
3
+ import type { LogEvent } from "../../core/logger.ts";
4
+
5
+ interface LogsContextValue {
6
+ logs: LogEvent[];
7
+ }
8
+
9
+ const LogsContext = createContext<LogsContextValue | null>(null);
10
+
11
+ export function LogsProvider({ children }: { children: ReactNode }) {
12
+ const [logs, setLogs] = useState<LogEvent[]>([]);
13
+
14
+ useEffect(() => {
15
+ const unsubscribe = AppContext.current.logger.onLogEvent((event: LogEvent) => {
16
+ setLogs((prev) => [...prev, event]);
17
+ });
18
+
19
+ return () => {
20
+ unsubscribe?.();
21
+ };
22
+ }, []);
23
+
24
+ const value = useMemo(() => ({ logs }), [logs]);
25
+
26
+ return <LogsContext.Provider value={value}>{children}</LogsContext.Provider>;
27
+ }
28
+
29
+ export function useLogs(): LogsContextValue {
30
+ const context = useContext(LogsContext);
31
+ if (!context) {
32
+ throw new Error("useLogs must be used within LogsProvider");
33
+ }
34
+ return context;
35
+ }
@@ -0,0 +1,194 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useReducer,
6
+ useRef,
7
+ useCallback,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ export interface ScreenEntry<TParams = unknown> {
12
+ route: string;
13
+ params?: TParams;
14
+ meta?: { focus?: string; breadcrumb?: string[] };
15
+ }
16
+
17
+ export interface ModalEntry<TParams = unknown> {
18
+ id: string;
19
+ params?: TParams;
20
+ }
21
+
22
+ /**
23
+ * Back handler function.
24
+ * Return true if handled, false to let navigation handle it.
25
+ */
26
+ export type BackHandler = () => boolean;
27
+
28
+ export interface NavigationAPI {
29
+ current: ScreenEntry;
30
+ stack: ScreenEntry[];
31
+ push: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
32
+ replace: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
33
+ reset: <TParams>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) => void;
34
+ pop: () => void;
35
+ canGoBack: boolean;
36
+
37
+ modalStack: ModalEntry[];
38
+ currentModal?: ModalEntry;
39
+ openModal: <TParams>(id: string, params?: TParams) => void;
40
+ closeModal: () => void;
41
+ hasModal: boolean;
42
+
43
+ /**
44
+ * Handle back/escape.
45
+ * 1. If modal is open, closes modal
46
+ * 2. Otherwise, calls the registered back handler (if any)
47
+ * 3. If no handler or handler returns false, pops the stack
48
+ */
49
+ goBack: () => void;
50
+
51
+ /**
52
+ * Register a back handler for the current screen.
53
+ * The handler is called when goBack() is invoked (after closing modals).
54
+ * Return true from the handler if it handled the back action.
55
+ */
56
+ setBackHandler: (handler: BackHandler | null) => void;
57
+ }
58
+
59
+ type NavigationProviderProps<TParams = unknown> = {
60
+ initialScreen: ScreenEntry<TParams>;
61
+ children: ReactNode;
62
+ /** Called when we can't go back anymore (at root with empty stack) */
63
+ onExit?: () => void;
64
+ };
65
+
66
+ type NavigationAction =
67
+ | { type: "push"; screen: ScreenEntry }
68
+ | { type: "replace"; screen: ScreenEntry }
69
+ | { type: "reset"; screen: ScreenEntry }
70
+ | { type: "pop" }
71
+ | { type: "openModal"; modal: ModalEntry }
72
+ | { type: "closeModal" };
73
+
74
+ type NavigationState = {
75
+ stack: ScreenEntry[];
76
+ modalStack: ModalEntry[];
77
+ };
78
+
79
+ function navigationReducer(
80
+ state: NavigationState,
81
+ action: NavigationAction
82
+ ): NavigationState {
83
+ switch (action.type) {
84
+ case "push":
85
+ return { ...state, stack: [...state.stack, action.screen] };
86
+ case "replace": {
87
+ const nextStack = state.stack.length === 0
88
+ ? [action.screen]
89
+ : [...state.stack.slice(0, -1), action.screen];
90
+ return { ...state, stack: nextStack };
91
+ }
92
+ case "reset":
93
+ return { ...state, stack: [action.screen] };
94
+ case "pop": {
95
+ if (state.stack.length <= 1) return state;
96
+ return { ...state, stack: state.stack.slice(0, -1) };
97
+ }
98
+ case "openModal":
99
+ return { ...state, modalStack: [...state.modalStack, action.modal] };
100
+ case "closeModal": {
101
+ if (state.modalStack.length === 0) return state;
102
+ return { ...state, modalStack: state.modalStack.slice(0, -1) };
103
+ }
104
+ default:
105
+ return state;
106
+ }
107
+ }
108
+
109
+ const NavigationContext = createContext<NavigationAPI | null>(null);
110
+
111
+ export function NavigationProvider<TParams = unknown>({
112
+ initialScreen,
113
+ children,
114
+ onExit,
115
+ }: NavigationProviderProps<TParams>) {
116
+ const [state, dispatch] = useReducer(navigationReducer, {
117
+ stack: [initialScreen],
118
+ modalStack: [],
119
+ });
120
+
121
+ // Back handler ref - set by the current screen
122
+ const backHandlerRef = useRef<BackHandler | null>(null);
123
+
124
+ const setBackHandler = useCallback((handler: BackHandler | null) => {
125
+ backHandlerRef.current = handler;
126
+ }, []);
127
+
128
+ const api = useMemo<NavigationAPI>(() => {
129
+ const stack = state.stack;
130
+ const modalStack = state.modalStack;
131
+ const current = stack[stack.length - 1]!;
132
+ const currentModal = modalStack[modalStack.length - 1];
133
+
134
+ const goBack = () => {
135
+ // 1. If modal is open, close it
136
+ if (modalStack.length > 0) {
137
+ dispatch({ type: "closeModal" });
138
+ return;
139
+ }
140
+
141
+ // 2. Let the screen's back handler try first
142
+ if (backHandlerRef.current) {
143
+ const handled = backHandlerRef.current();
144
+ if (handled) {
145
+ return;
146
+ }
147
+ }
148
+
149
+ // 3. Pop the stack if possible
150
+ if (stack.length > 1) {
151
+ dispatch({ type: "pop" });
152
+ return;
153
+ }
154
+
155
+ // 4. At root, call onExit
156
+ onExit?.();
157
+ };
158
+
159
+ return {
160
+ current,
161
+ stack,
162
+ push: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
163
+ dispatch({ type: "push", screen: { route, params, meta } }),
164
+ replace: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
165
+ dispatch({ type: "replace", screen: { route, params, meta } }),
166
+ reset: <TParams,>(route: string, params?: TParams, meta?: ScreenEntry["meta"]) =>
167
+ dispatch({ type: "reset", screen: { route, params, meta } }),
168
+ pop: () => dispatch({ type: "pop" }),
169
+ canGoBack: stack.length > 1 || modalStack.length > 0,
170
+ modalStack,
171
+ currentModal,
172
+ openModal: <TParams,>(id: string, params?: TParams) =>
173
+ dispatch({ type: "openModal", modal: { id, params } }),
174
+ closeModal: () => dispatch({ type: "closeModal" }),
175
+ hasModal: modalStack.length > 0,
176
+ goBack,
177
+ setBackHandler,
178
+ };
179
+ }, [state, onExit, setBackHandler]);
180
+
181
+ return (
182
+ <NavigationContext.Provider value={api}>
183
+ {children}
184
+ </NavigationContext.Provider>
185
+ );
186
+ }
187
+
188
+ export function useNavigation(): NavigationAPI {
189
+ const context = useContext(NavigationContext);
190
+ if (!context) {
191
+ throw new Error("useNavigation must be used within a NavigationProvider");
192
+ }
193
+ return context;
194
+ }
@@ -0,0 +1,20 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import type { Renderer } from "../adapters/types.ts";
3
+
4
+ const RendererContext = createContext<Renderer | null>(null);
5
+
6
+ export function RendererProvider({ renderer, children }: { renderer: Renderer; children: ReactNode }) {
7
+ return (
8
+ <RendererContext.Provider value={renderer}>
9
+ {children}
10
+ </RendererContext.Provider>
11
+ );
12
+ }
13
+
14
+ export function useRenderer(): Renderer {
15
+ const renderer = useContext(RendererContext);
16
+ if (!renderer) {
17
+ throw new Error("useRenderer must be used within RendererProvider");
18
+ }
19
+ return renderer;
20
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ type ReactNode,
5
+ } from "react";
6
+ import type { AnyCommand } from "../../core/command.ts";
7
+
8
+ /**
9
+ * App-level context value - provides app info and commands to screens.
10
+ */
11
+ export interface TuiAppContextValue {
12
+ /** Application name (used for persistence keys, CLI commands, etc.) */
13
+ name: string;
14
+ /** Display name for the header */
15
+ displayName?: string;
16
+ /** Application version */
17
+ version: string;
18
+ /** All available commands */
19
+ commands: AnyCommand[];
20
+ /** Exit the TUI application */
21
+ onExit: () => void;
22
+ }
23
+
24
+ const TuiAppContext = createContext<TuiAppContextValue | null>(null);
25
+
26
+ interface TuiAppContextProviderProps extends TuiAppContextValue {
27
+ children: ReactNode;
28
+ }
29
+
30
+ /**
31
+ * Provider that gives screens access to app-level information.
32
+ */
33
+ export function TuiAppContextProvider({
34
+ children,
35
+ name,
36
+ displayName,
37
+ version,
38
+ commands,
39
+ onExit,
40
+ }: TuiAppContextProviderProps) {
41
+ return (
42
+ <TuiAppContext.Provider value={{ name, displayName, version, commands, onExit }}>
43
+ {children}
44
+ </TuiAppContext.Provider>
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Access the TUI app context.
50
+ * @throws Error if used outside of TuiAppContextProvider
51
+ */
52
+ export function useTuiApp(): TuiAppContextValue {
53
+ const context = useContext(TuiAppContext);
54
+ if (!context) {
55
+ throw new Error("useTuiApp must be used within a TuiAppContextProvider");
56
+ }
57
+ return context;
58
+ }