@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,27 +1,178 @@
1
1
  import { createCliRenderer, type CliRenderer } from "@opentui/core";
2
2
  import { createRoot, type Root } from "@opentui/react";
3
- import { useLayoutEffect, type ReactNode } from "react";
3
+ import { useEffect, useLayoutEffect, type ReactNode } from "react";
4
4
  import { SemanticColors } from "../../theme.ts";
5
- import type { Renderer, RendererConfig } from "../types.ts";
5
+ import type { KeyboardEvent, Renderer, RendererConfig } from "../types.ts";
6
+ import { SemanticOpenTuiRenderer } from "./SemanticOpenTuiRenderer.tsx";
6
7
  import { useOpenTuiKeyboardAdapter } from "./keyboard.ts";
7
- import { Button } from "./components/Button.tsx";
8
- import { Code } from "./components/Code.tsx";
9
- import { CodeHighlight } from "./components/CodeHighlight.tsx";
10
- import { Container } from "./components/Container.tsx";
11
- import { Field } from "./components/Field.tsx";
12
- import { Label } from "./components/Label.tsx";
13
- import { MenuButton } from "./components/MenuButton.tsx";
14
- import { MenuItem } from "./components/MenuItem.tsx";
15
- import { Overlay } from "./components/Overlay.tsx";
16
- import { Spacer } from "./components/Spacer.tsx";
17
- import { Spinner } from "./components/Spinner.tsx";
18
- import { Panel } from "./components/Panel.tsx";
19
- import { ScrollView as OpenTuiScrollView } from "./components/ScrollView.tsx";
20
- import { Select } from "./components/Select.tsx";
21
- import { TextInput } from "./components/TextInput.tsx";
22
- import { Value } from "./components/Value.tsx";
8
+
9
+ import { copyToTerminalClipboard } from "../shared/TerminalClipboard.ts";
10
+ import { useTuiDriver } from "../../driver/context/TuiDriverContext.tsx";
11
+ import type { TuiAction } from "../../actions.ts";
12
+
13
+ function OpenTuiKeyboardHandler({
14
+ dispatchAction,
15
+ getScreenKeyHandler,
16
+ keyboard,
17
+ onCopyToastChange,
18
+ }: {
19
+ dispatchAction: (action: TuiAction) => void;
20
+ getScreenKeyHandler: () => ((event: KeyboardEvent) => boolean) | null;
21
+ keyboard: Renderer["keyboard"];
22
+ onCopyToastChange?: (toast: string | null) => void;
23
+ }) {
24
+ const driver = useTuiDriver();
25
+
26
+ useEffect(() => {
27
+ const cleanup = keyboard.setGlobalHandler((event) => {
28
+ if (event.name === "escape") {
29
+ dispatchAction({ type: "nav.back" });
30
+ return true;
31
+ }
32
+
33
+ if (event.ctrl && event.name === "y") {
34
+ const payload = driver.getActiveCopyPayload();
35
+ if (payload) {
36
+ // Show toast immediately for instant feedback
37
+ onCopyToastChange?.(`Copied ${payload.label}`);
38
+ void copyToTerminalClipboard(payload.content).then((success) => {
39
+ if (!success) {
40
+ onCopyToastChange?.("Copy failed");
41
+ }
42
+ setTimeout(() => onCopyToastChange?.(null), 1500);
43
+ });
44
+ }
45
+ return true;
46
+ }
47
+
48
+ if (event.ctrl && event.name === "l") {
49
+ dispatchAction({ type: "logs.open" });
50
+ return true;
51
+ }
52
+
53
+ const screenHandler = getScreenKeyHandler();
54
+ if (screenHandler) {
55
+ return screenHandler(event);
56
+ }
57
+
58
+ return false;
59
+ });
60
+
61
+ return cleanup;
62
+ }, [dispatchAction, getScreenKeyHandler, keyboard, driver, onCopyToastChange]);
63
+
64
+ return null;
65
+ }
23
66
 
24
67
  export class OpenTuiRenderer implements Renderer {
68
+ private readonly semanticRenderer = new SemanticOpenTuiRenderer();
69
+
70
+ private semanticScreenKeyHandler: ((event: KeyboardEvent) => boolean) | null = null;
71
+
72
+ public renderSemanticAppShell: Renderer["renderSemanticAppShell"] = (props) => {
73
+ return this.semanticRenderer.renderAppShell(props);
74
+ };
75
+ public renderSemanticCommandBrowserScreen: Renderer["renderSemanticCommandBrowserScreen"] = (props) => {
76
+ this.semanticScreenKeyHandler = (event) => {
77
+ if (event.ctrl && event.name === "l") {
78
+ // Adapter-owned logs open — let global handler process it.
79
+ return false;
80
+ }
81
+
82
+ if (event.name === "up") {
83
+ props.onSelectCommand(props.selectedCommandIndex - 1);
84
+ return true;
85
+ }
86
+
87
+ if (event.name === "down") {
88
+ props.onSelectCommand(props.selectedCommandIndex + 1);
89
+ return true;
90
+ }
91
+
92
+ if (event.name === "return") {
93
+ props.onRunSelected();
94
+ return true;
95
+ }
96
+
97
+ return false;
98
+ };
99
+
100
+ return this.semanticRenderer.renderCommandBrowserScreen(props);
101
+ };
102
+
103
+ public renderSemanticConfigScreen: Renderer["renderSemanticConfigScreen"] = (props) => {
104
+ this.semanticScreenKeyHandler = (event) => {
105
+ if (event.ctrl && event.name === "l") {
106
+ // Adapter-owned logs open — let global handler process it.
107
+ return false;
108
+ }
109
+
110
+ if (event.name === "up") {
111
+ props.onSelectionChange(props.selectedFieldIndex - 1);
112
+ return true;
113
+ }
114
+
115
+ if (event.name === "down") {
116
+ props.onSelectionChange(props.selectedFieldIndex + 1);
117
+ return true;
118
+ }
119
+
120
+ if (event.name === "return") {
121
+ const fieldConfig = props.fieldConfigs[props.selectedFieldIndex];
122
+ if (fieldConfig) {
123
+ props.onEditField(fieldConfig.key);
124
+ } else {
125
+ props.onRun();
126
+ }
127
+ return true;
128
+ }
129
+
130
+ return false;
131
+ };
132
+
133
+ return this.semanticRenderer.renderConfigScreen(props);
134
+ };
135
+
136
+ public renderSemanticRunningScreen: Renderer["renderSemanticRunningScreen"] = (props) => {
137
+ this.semanticScreenKeyHandler = null;
138
+ return this.semanticRenderer.renderRunningScreen(props);
139
+ };
140
+
141
+ public renderSemanticLogsScreen: Renderer["renderSemanticLogsScreen"] = (props) => {
142
+ this.semanticScreenKeyHandler = (event) => {
143
+ if (event.name === "return") {
144
+ props.onClose();
145
+ return true;
146
+ }
147
+ return false;
148
+ };
149
+
150
+ return this.semanticRenderer.renderLogsScreen(props);
151
+ };
152
+
153
+ public renderSemanticEditorScreen: Renderer["renderSemanticEditorScreen"] = (props) => {
154
+ this.semanticScreenKeyHandler = (event) => {
155
+ if (event.name === "return") {
156
+ props.onSubmit?.();
157
+ return true;
158
+ }
159
+ return false;
160
+ };
161
+
162
+ return this.semanticRenderer.renderEditorScreen(props);
163
+ };
164
+
165
+ public renderKeyboardHandler: Renderer["renderKeyboardHandler"] = ({ dispatchAction, onCopyToastChange }) => {
166
+ return (
167
+ <OpenTuiKeyboardHandlerWrapper
168
+ dispatchAction={dispatchAction}
169
+ getScreenKeyHandler={() => this.semanticScreenKeyHandler}
170
+ keyboard={this.keyboard}
171
+ onCopyToastChange={onCopyToastChange}
172
+ />
173
+ );
174
+ };
175
+
25
176
  private renderer: CliRenderer | null = null;
26
177
  private root: Root | null = null;
27
178
 
@@ -36,27 +187,6 @@ export class OpenTuiRenderer implements Renderer {
36
187
  },
37
188
  };
38
189
 
39
- public components: Renderer["components"] = {
40
- Field,
41
- Button,
42
- MenuButton,
43
- MenuItem,
44
- Container,
45
- Panel,
46
- ScrollView: OpenTuiScrollView,
47
-
48
- Overlay,
49
- Spacer,
50
- Spinner,
51
- Label,
52
- Value,
53
- Code,
54
- CodeHighlight,
55
-
56
- Select,
57
- TextInput,
58
- };
59
-
60
190
  constructor(private readonly config: RendererConfig) {}
61
191
 
62
192
  async initialize(): Promise<void> {
@@ -113,3 +243,24 @@ function KeyboardBridge({
113
243
 
114
244
  return <>{children}</>;
115
245
  }
246
+
247
+ function OpenTuiKeyboardHandlerWrapper({
248
+ dispatchAction,
249
+ getScreenKeyHandler,
250
+ keyboard,
251
+ onCopyToastChange,
252
+ }: {
253
+ dispatchAction: (action: TuiAction) => void;
254
+ getScreenKeyHandler: () => ((event: KeyboardEvent) => boolean) | null;
255
+ keyboard: Renderer["keyboard"];
256
+ onCopyToastChange?: (toast: string | null) => void;
257
+ }) {
258
+ return (
259
+ <OpenTuiKeyboardHandler
260
+ dispatchAction={dispatchAction}
261
+ getScreenKeyHandler={getScreenKeyHandler}
262
+ keyboard={keyboard}
263
+ onCopyToastChange={onCopyToastChange}
264
+ />
265
+ );
266
+ }
@@ -0,0 +1,192 @@
1
+ import type { ReactNode } from "react";
2
+ import type { AppShellProps } from "../../semantic/AppShell.tsx";
3
+ import type { CommandBrowserScreenProps } from "../../semantic/CommandBrowserScreen.tsx";
4
+ import type { ConfigScreenProps } from "../../semantic/ConfigScreen.tsx";
5
+ import type { RunningScreenProps } from "../../semantic/RunningScreen.tsx";
6
+ import type { LogsScreenProps } from "../../semantic/LogsScreen.tsx";
7
+ import type { EditorScreenProps } from "../../semantic/EditorScreen.tsx";
8
+
9
+ // Platform-native components (OpenTUI)
10
+ import { Panel } from "./components/Panel.tsx";
11
+ import { Label } from "./components/Label.tsx";
12
+ import { Overlay } from "./components/Overlay.tsx";
13
+ import { TextInput } from "./components/TextInput.tsx";
14
+ import { Select } from "./components/Select.tsx";
15
+ import { MenuButton } from "./components/MenuButton.tsx";
16
+ import { Spinner } from "./components/Spinner.tsx";
17
+
18
+ // Adapter-local UI components
19
+ import { Header } from "./ui/Header.tsx";
20
+ import { CommandSelector } from "./ui/CommandSelector.tsx";
21
+ import { ConfigForm } from "./ui/ConfigForm.tsx";
22
+ import { ResultsPanel } from "./ui/ResultsPanel.tsx";
23
+ import { LogsPanel } from "./ui/LogsPanel.tsx";
24
+
25
+ export class SemanticOpenTuiRenderer {
26
+ renderAppShell(props: AppShellProps): ReactNode {
27
+ return (
28
+ <Panel flexDirection="column" flex={1} padding={1} border={false}>
29
+ <box flexDirection="column" flexGrow={1}>
30
+ <Header
31
+ name={props.app.displayName ?? props.app.name}
32
+ version={props.app.version}
33
+ breadcrumb={props.app.breadcrumb}
34
+ />
35
+
36
+ <box flexDirection="column" flexGrow={1}>
37
+ {props.screen}
38
+ </box>
39
+
40
+ <Panel dense border={true} flexDirection="column" gap={0} height={4}>
41
+ <box flexDirection="row" justifyContent="space-between" paddingLeft={1} paddingRight={1}>
42
+ <box flexDirection="row" gap={1}>
43
+ <Spinner active={props.status.isExecuting} />
44
+ <Label color="mutedText">
45
+ {props.status.isCancelling
46
+ ? "Cancelling..."
47
+ : props.status.isExecuting
48
+ ? "Executing..."
49
+ : "Ready"}
50
+ </Label>
51
+ {props.copyToast ? (
52
+ <Label color="success" bold>{props.copyToast}</Label>
53
+ ) : null}
54
+ </box>
55
+ <Label color="mutedText">Esc Back Ctrl+L Logs Ctrl+Y Copy</Label>
56
+ </box>
57
+ </Panel>
58
+
59
+ {props.modals}
60
+ </box>
61
+ </Panel>
62
+ );
63
+ }
64
+
65
+ renderCommandBrowserScreen(props: CommandBrowserScreenProps): ReactNode {
66
+ const commandItems = props.commands.map((command) => ({ command }));
67
+
68
+ return (
69
+ <CommandSelector
70
+ commands={commandItems}
71
+ selectedIndex={props.selectedCommandIndex}
72
+ onSelect={() => {
73
+ // Controller handles subcommand navigation in onRunSelected
74
+ props.onRunSelected();
75
+ }}
76
+ breadcrumb={props.commandId}
77
+ />
78
+ );
79
+ }
80
+
81
+ renderConfigScreen(props: ConfigScreenProps): ReactNode {
82
+ const additionalButtons: { label: string; onPress: () => void }[] = [];
83
+
84
+ return (
85
+ <box flexDirection="column" flexGrow={1}>
86
+ <ConfigForm
87
+ title={props.title}
88
+ fieldConfigs={props.fieldConfigs}
89
+ values={props.values}
90
+ selectedIndex={props.selectedFieldIndex}
91
+ focused={true}
92
+ additionalButtons={additionalButtons}
93
+ actionButton={
94
+ <MenuButton
95
+ label={"Done"}
96
+ selected={props.selectedFieldIndex === props.fieldConfigs.length + additionalButtons.length}
97
+ />
98
+ }
99
+ />
100
+ <box flexDirection="column" paddingTop={1}>
101
+ <Label color="mutedText">CLI: {props.cliCommand}</Label>
102
+ </box>
103
+ </box>
104
+ );
105
+ }
106
+
107
+ renderRunningScreen(props: RunningScreenProps): ReactNode {
108
+ if (props.kind === "running") {
109
+ return (
110
+ <box flexDirection="column" flexGrow={1}>
111
+ <ResultsPanel result={{ success: true, message: props.title }} error={null} focused={true} />
112
+ </box>
113
+ );
114
+ }
115
+
116
+ if (props.kind === "error") {
117
+ return (
118
+ <box flexDirection="column" flexGrow={1}>
119
+ <ResultsPanel result={null} error={new Error(props.message ?? "Unknown error")} focused={true} />
120
+ </box>
121
+ );
122
+ }
123
+
124
+ // kind === "results"
125
+ // If customContent is provided, render it directly instead of using ResultsPanel's default rendering
126
+ if (props.customContent !== undefined) {
127
+ return (
128
+ <box flexDirection="column" flexGrow={1}>
129
+ <ResultsPanel
130
+ result={props.result ?? { success: true, message: props.message }}
131
+ error={null}
132
+ focused={true}
133
+ renderResult={() => props.customContent}
134
+ />
135
+ </box>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <box flexDirection="column" flexGrow={1}>
141
+ <ResultsPanel
142
+ result={props.result ?? { success: true, message: props.message }}
143
+ error={null}
144
+ focused={true}
145
+ />
146
+ </box>
147
+ );
148
+ }
149
+
150
+ renderLogsScreen(props: LogsScreenProps): ReactNode {
151
+ return <LogsPanel {...props} />;
152
+ }
153
+
154
+ renderEditorScreen(props: EditorScreenProps): ReactNode {
155
+ // For text input, use more height to give a proper editing area
156
+ const isTextEditor = props.editorType !== "select";
157
+ const panelHeight = isTextEditor ? 8 : undefined;
158
+
159
+ return (
160
+ <Overlay>
161
+ <Panel flexDirection="column" padding={1} border={true} width={80} height={panelHeight} surface="overlay">
162
+ <Label bold>{props.label ?? props.fieldId}</Label>
163
+
164
+ <box flexDirection="column" gap={1} flexGrow={1}>
165
+ {props.editorType === "select" ? (
166
+ <Select
167
+ options={props.selectOptions ?? []}
168
+ value={props.valueString}
169
+ focused={true}
170
+ onChange={(value: string) => {
171
+ const index = (props.selectOptions ?? []).findIndex((o) => o.value === value);
172
+ props.onChangeSelectIndex?.(Math.max(0, index));
173
+ }}
174
+ onSubmit={() => props.onSubmit?.()}
175
+ />
176
+ ) : (
177
+ <TextInput
178
+ value={props.valueString}
179
+ placeholder=""
180
+ focused={true}
181
+ onChange={(next: string) => props.onChangeText?.(next)}
182
+ onSubmit={() => props.onSubmit?.()}
183
+ />
184
+ )}
185
+ </box>
186
+
187
+ <Label color="mutedText">Enter to submit Esc to cancel</Label>
188
+ </Panel>
189
+ </Overlay>
190
+ );
191
+ }
192
+ }
@@ -2,13 +2,13 @@ import type { ReactNode } from "react";
2
2
  import type { LabelProps } from "../../../semantic/types.ts";
3
3
  import { SemanticColors } from "../../../theme.ts";
4
4
 
5
- export function Label({ color = "text", bold, italic, wrap, children }: LabelProps & { children: ReactNode }) {
5
+ export function Label({ color = "text", bold, italic, children }: LabelProps & { children: ReactNode }) {
6
6
  const fg = SemanticColors[color] ?? SemanticColors.text;
7
7
 
8
8
  const content = bold ? <strong>{children}</strong> : children;
9
9
 
10
10
  return (
11
- <text fg={fg} {...({ wrap } as any)}>
11
+ <text fg={fg}>
12
12
  {italic ? <em>{content}</em> : content}
13
13
  </text>
14
14
  );
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { OverlayProps } from "../../../semantic/types.ts";
2
+ import type { OverlayProps } from "../../../semantic/layoutTypes.ts";
3
3
 
4
4
  export function Overlay({
5
5
  zIndex = 10,
@@ -12,8 +12,17 @@ export function Overlay({
12
12
  children,
13
13
  }: OverlayProps & { children?: ReactNode }) {
14
14
  return (
15
- <box position="absolute" top={0} left={0} right={0} bottom={0} zIndex={zIndex}>
16
- <box position="absolute" top={top as any} left={left as any} right={right as any} bottom={bottom as any} width={width as any} height={height as any}>
15
+ <box
16
+ position="absolute"
17
+ top={0}
18
+ left={0}
19
+ right={0}
20
+ bottom={0}
21
+ zIndex={zIndex}
22
+ alignItems="center"
23
+ justifyContent="center"
24
+ >
25
+ <box top={top as any} left={left as any} right={right as any} bottom={bottom as any} width={width as any} height={height as any}>
17
26
  {children}
18
27
  </box>
19
28
  </box>
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { PanelProps, Spacing } from "../../../semantic/types.ts";
2
+ import type { PanelProps, Spacing } from "../../../semantic/layoutTypes.ts";
3
3
  import { SemanticColors } from "../../../theme.ts";
4
4
 
5
5
  function normalizePadding(
@@ -38,6 +38,11 @@ export function Panel({
38
38
  flex,
39
39
  width,
40
40
  height,
41
+ maxHeight,
42
+ top,
43
+ left,
44
+ right,
45
+ bottom,
41
46
  flexDirection,
42
47
  alignItems,
43
48
  justifyContent,
@@ -66,6 +71,11 @@ export function Panel({
66
71
  flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
67
72
  width={width as any}
68
73
  height={height as any}
74
+ maxHeight={maxHeight as any}
75
+ top={top as any}
76
+ left={left as any}
77
+ right={right as any}
78
+ bottom={bottom as any}
69
79
  flexDirection={flexDirection as any}
70
80
  alignItems={alignItems as any}
71
81
  justifyContent={justifyContent as any}
@@ -1,6 +1,6 @@
1
1
  import { useRef, type ReactNode } from "react";
2
2
  import type { ScrollBoxRenderable } from "@opentui/core";
3
- import type { ScrollViewProps, ScrollViewRef, Spacing } from "../../../semantic/types.ts";
3
+ import type { ScrollViewProps, ScrollViewRef, Spacing } from "../../../semantic/layoutTypes.ts";
4
4
 
5
5
  function normalizePadding(padding: number | Spacing | undefined): any {
6
6
  if (padding === undefined) {
@@ -37,13 +37,6 @@ export function ScrollView({
37
37
  const scrollRef = useRef<ScrollBoxRenderable>(null);
38
38
 
39
39
  const imperativeApi: ScrollViewRef = {
40
- scrollToTop: () => {
41
- scrollRef.current?.scrollTo(0);
42
- },
43
- scrollToBottom: () => {
44
- // No public "bottom" API in ScrollBoxRenderable; use large index.
45
- scrollRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
46
- },
47
40
  scrollToIndex: (index: number) => {
48
41
  scrollRef.current?.scrollTo(index);
49
42
  },
@@ -1,5 +1,5 @@
1
1
  import type { SpinnerProps } from "../../../semantic/types.ts";
2
- import { useSpinner } from "../hooks/useSpinner.ts";
2
+ import { useSpinner } from "../../shared/useSpinner.ts";
3
3
 
4
4
  export function Spinner({ active }: SpinnerProps) {
5
5
  const { frame } = useSpinner(active);
@@ -6,10 +6,7 @@ import type { KeyboardAdapter, KeyboardEvent, KeyHandler } from "../types.ts";
6
6
  function normalizeKeyEvent(key: KeyEvent): KeyboardEvent {
7
7
  return {
8
8
  name: key.name,
9
- sequence: key.sequence,
10
9
  ctrl: key.ctrl,
11
- shift: key.shift,
12
- meta: key.meta,
13
10
  };
14
11
  }
15
12
 
@@ -0,0 +1,55 @@
1
+ import type { Command } from "../../../../core/command.ts";
2
+ import { MenuItem } from "../components/MenuItem.tsx";
3
+ import { Panel } from "../components/Panel.tsx";
4
+
5
+ interface CommandItem {
6
+ command: Command;
7
+ label?: string;
8
+ description?: string;
9
+ }
10
+
11
+ interface CommandSelectorProps {
12
+ commands: CommandItem[];
13
+ selectedIndex: number;
14
+ onSelect: (command: Command) => void;
15
+ breadcrumb?: string[];
16
+ }
17
+
18
+ export function CommandSelector({ commands, selectedIndex, onSelect, breadcrumb }: CommandSelectorProps) {
19
+ const title = breadcrumb?.length ? `Select Command (${breadcrumb.join(" > ")})` : "Select Command";
20
+
21
+ return (
22
+ <box flexDirection="column" flexGrow={1} justifyContent="center" alignItems="center" gap={1}>
23
+ <Panel flexDirection="column" title={title} padding={undefined} width={"80%"} focused>
24
+ <box flexDirection="column" gap={1}>
25
+ {commands.map((item, idx) => {
26
+ const isSelected = idx === selectedIndex;
27
+ const label = item.label ?? item.command.displayName ?? item.command.name;
28
+ const description = item.description ?? item.command.description;
29
+ const modeIndicator = getModeIndicator(item.command);
30
+
31
+ return (
32
+ <MenuItem
33
+ key={item.command.name}
34
+ label={label}
35
+ description={description}
36
+ suffix={modeIndicator}
37
+ selected={isSelected}
38
+ onActivate={() => onSelect(item.command)}
39
+ />
40
+ );
41
+ })}
42
+ </box>
43
+ </Panel>
44
+ </box>
45
+ );
46
+ }
47
+
48
+ function getModeIndicator(command: Command): string {
49
+ const navigableSubCommands = command.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
50
+ if (navigableSubCommands.length > 0) {
51
+ return ">";
52
+ }
53
+
54
+ return "";
55
+ }