@pablozaiden/terminatui 0.2.0 → 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 +14 -2
  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 +6 -10
  6. package/examples/tui-app/commands/config/app/index.ts +2 -6
  7. package/examples/tui-app/commands/config/app/set.ts +23 -13
  8. package/examples/tui-app/commands/config/index.ts +2 -6
  9. package/examples/tui-app/commands/config/user/get.ts +6 -10
  10. package/examples/tui-app/commands/config/user/index.ts +2 -6
  11. package/examples/tui-app/commands/config/user/set.ts +6 -10
  12. package/examples/tui-app/commands/greet.ts +13 -11
  13. package/examples/tui-app/commands/math.ts +5 -9
  14. package/examples/tui-app/commands/status.ts +21 -12
  15. package/examples/tui-app/index.ts +6 -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 +14 -16
  23. package/guides/08-complete-application.md +12 -42
  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 +12 -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 +45 -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 -4
  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 -619
  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,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
+ }