@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.
- package/package.json +10 -3
- package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
- package/src/__tests__/configOnChange.test.ts +63 -0
- package/src/__tests__/schemaToFields.test.ts +0 -4
- package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
- package/src/builtins/version.ts +1 -1
- package/src/index.ts +22 -0
- package/src/tui/TuiApplication.tsx +0 -4
- package/src/tui/TuiRoot.tsx +58 -102
- package/src/tui/actions.ts +4 -0
- package/src/tui/adapters/ink/InkRenderer.tsx +191 -41
- package/src/tui/adapters/ink/SemanticInkRenderer.tsx +210 -0
- package/src/tui/adapters/ink/components/Button.tsx +10 -2
- package/src/tui/adapters/ink/components/Overlay.tsx +8 -2
- package/src/tui/adapters/ink/components/Panel.tsx +26 -5
- package/src/tui/adapters/ink/components/ScrollView.tsx +44 -3
- package/src/tui/adapters/ink/components/Spinner.tsx +8 -2
- package/src/tui/adapters/ink/keyboard.ts +0 -3
- package/src/tui/adapters/ink/ui/CommandSelector.tsx +56 -0
- package/src/tui/adapters/ink/ui/ConfigForm.tsx +77 -0
- package/src/tui/adapters/ink/ui/Header.tsx +25 -0
- package/src/tui/adapters/ink/ui/JsonHighlight.tsx +21 -0
- package/src/tui/adapters/ink/ui/ResultsPanel.tsx +57 -0
- package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +190 -39
- package/src/tui/adapters/opentui/SemanticOpenTuiRenderer.tsx +192 -0
- package/src/tui/adapters/opentui/components/Label.tsx +2 -2
- package/src/tui/adapters/opentui/components/Overlay.tsx +12 -3
- package/src/tui/adapters/opentui/components/Panel.tsx +11 -1
- package/src/tui/adapters/opentui/components/ScrollView.tsx +1 -8
- package/src/tui/adapters/opentui/components/Spinner.tsx +1 -1
- package/src/tui/adapters/opentui/keyboard.ts +0 -3
- package/src/tui/adapters/opentui/ui/CommandSelector.tsx +55 -0
- package/src/tui/adapters/opentui/ui/ConfigForm.tsx +74 -0
- package/src/tui/adapters/opentui/ui/Header.tsx +24 -0
- package/src/tui/adapters/opentui/ui/JsonHighlight.tsx +20 -0
- package/src/tui/adapters/opentui/ui/LogsPanel.tsx +44 -0
- package/src/tui/adapters/opentui/ui/ResultsPanel.tsx +62 -0
- package/src/tui/adapters/shared/TerminalClipboard.ts +65 -0
- package/src/tui/adapters/{opentui/hooks → shared}/useSpinner.ts +5 -1
- package/src/tui/adapters/types.ts +25 -45
- package/src/tui/components/JsonHighlight.tsx +41 -111
- package/src/tui/context/ActionContext.tsx +51 -0
- package/src/tui/context/ExecutorContext.tsx +7 -1
- package/src/tui/context/NavigationContext.tsx +20 -4
- package/src/tui/controllers/CommandBrowserController.tsx +100 -0
- package/src/tui/controllers/ConfigController.tsx +183 -0
- package/src/tui/controllers/EditorController.tsx +169 -0
- package/src/tui/controllers/LogsController.tsx +48 -0
- package/src/tui/controllers/OutcomeController.tsx +110 -0
- package/src/tui/driver/TuiDriver.tsx +148 -0
- package/src/tui/driver/context/TuiDriverContext.tsx +44 -0
- package/src/tui/driver/types.ts +72 -0
- package/src/tui/semantic/AppShell.tsx +30 -0
- package/src/tui/semantic/CommandBrowserScreen.tsx +16 -0
- package/src/tui/semantic/ConfigScreen.tsx +23 -0
- package/src/tui/semantic/EditorScreen.tsx +20 -0
- package/src/tui/semantic/LogsScreen.tsx +9 -0
- package/src/tui/semantic/RunningScreen.tsx +17 -0
- package/src/tui/semantic/layoutTypes.ts +72 -0
- package/src/tui/semantic/render.tsx +44 -0
- package/src/tui/semantic/types.ts +31 -98
- package/src/tui/utils/jsonTokenizer.ts +98 -0
- package/src/tui/utils/schemaToFields.ts +1 -25
- package/.devcontainer/devcontainer.json +0 -19
- package/.devcontainer/install-prerequisites.sh +0 -49
- package/.github/workflows/copilot-setup-steps.yml +0 -32
- package/.github/workflows/pull-request.yml +0 -27
- package/.github/workflows/release-npm-package.yml +0 -81
- package/AGENTS.md +0 -43
- package/CLAUDE.md +0 -1
- package/bun.lock +0 -321
- package/examples/tui-app/commands/config/app/get.ts +0 -62
- package/examples/tui-app/commands/config/app/index.ts +0 -23
- package/examples/tui-app/commands/config/app/set.ts +0 -96
- package/examples/tui-app/commands/config/index.ts +0 -28
- package/examples/tui-app/commands/config/user/get.ts +0 -61
- package/examples/tui-app/commands/config/user/index.ts +0 -23
- package/examples/tui-app/commands/config/user/set.ts +0 -57
- package/examples/tui-app/commands/greet.ts +0 -78
- package/examples/tui-app/commands/math.ts +0 -111
- package/examples/tui-app/commands/status.ts +0 -86
- package/examples/tui-app/index.ts +0 -38
- package/guides/01-hello-world.md +0 -101
- package/guides/02-adding-options.md +0 -103
- package/guides/03-multiple-commands.md +0 -161
- package/guides/04-subcommands.md +0 -206
- package/guides/05-interactive-tui.md +0 -209
- package/guides/06-config-validation.md +0 -256
- package/guides/07-async-cancellation.md +0 -334
- package/guides/08-complete-application.md +0 -507
- package/guides/README.md +0 -78
- package/src/tui/adapters/ink/components/Code.tsx +0 -6
- package/src/tui/adapters/ink/components/Container.tsx +0 -5
- package/src/tui/adapters/ink/components/Spacer.tsx +0 -15
- package/src/tui/adapters/ink/components/Value.tsx +0 -7
- package/src/tui/adapters/opentui/components/Code.tsx +0 -12
- package/src/tui/adapters/opentui/components/Container.tsx +0 -56
- package/src/tui/adapters/opentui/components/Spacer.tsx +0 -5
- package/src/tui/adapters/opentui/components/Value.tsx +0 -13
- package/src/tui/components/ActionButton.tsx +0 -0
- package/src/tui/components/CommandSelector.tsx +0 -119
- package/src/tui/components/ConfigForm.tsx +0 -174
- package/src/tui/components/FieldRow.tsx +0 -0
- package/src/tui/components/Header.tsx +0 -32
- package/src/tui/components/ModalBase.tsx +0 -38
- package/src/tui/components/ResultsPanel.tsx +0 -84
- package/src/tui/components/StatusBar.tsx +0 -44
- package/src/tui/components/logColors.ts +0 -12
- package/src/tui/components/types.ts +0 -30
- package/src/tui/context/ClipboardContext.tsx +0 -87
- package/src/tui/context/KeyboardContext.tsx +0 -132
- package/src/tui/hooks/useActiveKeyHandler.ts +0 -75
- package/src/tui/hooks/useClipboard.ts +0 -81
- package/src/tui/hooks/useClipboardProvider.ts +0 -42
- package/src/tui/hooks/useGlobalKeyHandler.ts +0 -54
- package/src/tui/modals/CliModal.tsx +0 -82
- package/src/tui/modals/EditorModal.tsx +0 -207
- package/src/tui/modals/LogsModal.tsx +0 -98
- package/src/tui/registry.ts +0 -102
- package/src/tui/screens/CommandSelectScreen.tsx +0 -162
- package/src/tui/screens/ConfigScreen.tsx +0 -160
- package/src/tui/screens/ErrorScreen.tsx +0 -58
- package/src/tui/screens/ResultsScreen.tsx +0 -60
- package/src/tui/screens/RunningScreen.tsx +0 -72
- package/src/tui/screens/ScreenBase.ts +0 -6
- package/src/tui/semantic/Button.tsx +0 -7
- package/src/tui/semantic/Code.tsx +0 -7
- package/src/tui/semantic/CodeHighlight.tsx +0 -7
- package/src/tui/semantic/Container.tsx +0 -7
- package/src/tui/semantic/Field.tsx +0 -7
- package/src/tui/semantic/Label.tsx +0 -7
- package/src/tui/semantic/MenuButton.tsx +0 -7
- package/src/tui/semantic/MenuItem.tsx +0 -7
- package/src/tui/semantic/Overlay.tsx +0 -7
- package/src/tui/semantic/Panel.tsx +0 -7
- package/src/tui/semantic/ScrollView.tsx +0 -9
- package/src/tui/semantic/Select.tsx +0 -7
- package/src/tui/semantic/Spacer.tsx +0 -7
- package/src/tui/semantic/Spinner.tsx +0 -7
- package/src/tui/semantic/TextInput.tsx +0 -7
- package/src/tui/semantic/Value.tsx +0 -7
- package/tsconfig.json +0 -25
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import type { AppShellProps } from "../../semantic/AppShell.tsx";
|
|
4
|
+
import type { CommandBrowserScreenProps } from "../../semantic/CommandBrowserScreen.tsx";
|
|
5
|
+
import type { ConfigScreenProps } from "../../semantic/ConfigScreen.tsx";
|
|
6
|
+
import type { RunningScreenProps } from "../../semantic/RunningScreen.tsx";
|
|
7
|
+
import type { LogsScreenProps } from "../../semantic/LogsScreen.tsx";
|
|
8
|
+
import type { EditorScreenProps } from "../../semantic/EditorScreen.tsx";
|
|
9
|
+
|
|
10
|
+
// Platform-native components (Ink)
|
|
11
|
+
import { Panel } from "./components/Panel.tsx";
|
|
12
|
+
import { Label } from "./components/Label.tsx";
|
|
13
|
+
import { Overlay } from "./components/Overlay.tsx";
|
|
14
|
+
import { ScrollView } from "./components/ScrollView.tsx";
|
|
15
|
+
import { TextInput } from "./components/TextInput.tsx";
|
|
16
|
+
import { Select } from "./components/Select.tsx";
|
|
17
|
+
import { MenuButton } from "./components/MenuButton.tsx";
|
|
18
|
+
import { Spinner } from "./components/Spinner.tsx";
|
|
19
|
+
|
|
20
|
+
// Adapter-local UI components
|
|
21
|
+
import { Header } from "./ui/Header.tsx";
|
|
22
|
+
import { CommandSelector } from "./ui/CommandSelector.tsx";
|
|
23
|
+
import { ConfigForm } from "./ui/ConfigForm.tsx";
|
|
24
|
+
import { ResultsPanel } from "./ui/ResultsPanel.tsx";
|
|
25
|
+
|
|
26
|
+
function SemanticInkAppShell(props: AppShellProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Panel flexDirection="column" flex={1} padding={0} 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} padding={0}>
|
|
37
|
+
{props.screen}
|
|
38
|
+
</Box>
|
|
39
|
+
|
|
40
|
+
<Panel dense border={true} flexDirection="column" gap={0} height={4}>
|
|
41
|
+
<Box height={1} />
|
|
42
|
+
<Box flexDirection="column">
|
|
43
|
+
<Box flexDirection="row" gap={1}>
|
|
44
|
+
<Spinner active={props.status.isExecuting} />
|
|
45
|
+
<Label color="mutedText">
|
|
46
|
+
{props.status.isCancelling
|
|
47
|
+
? "Cancelling..."
|
|
48
|
+
: props.status.isExecuting
|
|
49
|
+
? "Executing..."
|
|
50
|
+
: "Ready"}
|
|
51
|
+
</Label>
|
|
52
|
+
{props.copyToast ? (
|
|
53
|
+
<Label color="success" bold>{props.copyToast}</Label>
|
|
54
|
+
) : null}
|
|
55
|
+
</Box>
|
|
56
|
+
<Box>
|
|
57
|
+
<Label color="mutedText">Esc Back Ctrl+L Logs Ctrl+Y Copy</Label>
|
|
58
|
+
</Box>
|
|
59
|
+
</Box>
|
|
60
|
+
</Panel>
|
|
61
|
+
|
|
62
|
+
{props.modals}
|
|
63
|
+
</Box>
|
|
64
|
+
</Panel>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class SemanticInkRenderer {
|
|
69
|
+
renderAppShell(props: AppShellProps): ReactNode {
|
|
70
|
+
return <SemanticInkAppShell {...props} />;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
renderCommandBrowserScreen(props: CommandBrowserScreenProps): ReactNode {
|
|
74
|
+
const commandItems = props.commands.map((command) => ({ command }));
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<CommandSelector
|
|
78
|
+
commands={commandItems}
|
|
79
|
+
selectedIndex={props.selectedCommandIndex}
|
|
80
|
+
onSelect={() => {
|
|
81
|
+
// Controller handles subcommand navigation in onRunSelected
|
|
82
|
+
props.onRunSelected();
|
|
83
|
+
}}
|
|
84
|
+
breadcrumb={props.commandId}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
renderConfigScreen(props: ConfigScreenProps): ReactNode {
|
|
90
|
+
const additionalButtons: { label: string; onPress: () => void }[] = [];
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
94
|
+
<ConfigForm
|
|
95
|
+
title={props.title}
|
|
96
|
+
fieldConfigs={props.fieldConfigs}
|
|
97
|
+
values={props.values}
|
|
98
|
+
selectedIndex={props.selectedFieldIndex}
|
|
99
|
+
focused={true}
|
|
100
|
+
additionalButtons={additionalButtons}
|
|
101
|
+
actionButton={
|
|
102
|
+
<MenuButton
|
|
103
|
+
label={"Done"}
|
|
104
|
+
selected={props.selectedFieldIndex === props.fieldConfigs.length + additionalButtons.length}
|
|
105
|
+
/>
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
<Box flexDirection="column" paddingTop={1}>
|
|
109
|
+
<Label color="mutedText">CLI: {props.cliCommand}</Label>
|
|
110
|
+
</Box>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
renderRunningScreen(props: RunningScreenProps): ReactNode {
|
|
116
|
+
if (props.kind === "running") {
|
|
117
|
+
return (
|
|
118
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
119
|
+
<ResultsPanel result={{ success: true, message: props.title }} error={null} focused={true} />
|
|
120
|
+
</Box>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (props.kind === "error") {
|
|
125
|
+
return (
|
|
126
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
127
|
+
<ResultsPanel result={null} error={new Error(props.message ?? "Unknown error")} focused={true} />
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// kind === "results"
|
|
133
|
+
// If customContent is provided, render it directly instead of using ResultsPanel's default rendering
|
|
134
|
+
if (props.customContent !== undefined) {
|
|
135
|
+
return (
|
|
136
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
137
|
+
<ResultsPanel
|
|
138
|
+
result={props.result ?? { success: true, message: props.message }}
|
|
139
|
+
error={null}
|
|
140
|
+
focused={true}
|
|
141
|
+
renderResult={() => props.customContent}
|
|
142
|
+
/>
|
|
143
|
+
</Box>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
149
|
+
<ResultsPanel
|
|
150
|
+
result={props.result ?? { success: true, message: props.message }}
|
|
151
|
+
error={null}
|
|
152
|
+
focused={true}
|
|
153
|
+
/>
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
renderLogsScreen(props: LogsScreenProps): ReactNode {
|
|
159
|
+
return (
|
|
160
|
+
<Overlay>
|
|
161
|
+
<Panel flexDirection="column" padding={0} border={true} width={80} height={20}>
|
|
162
|
+
<Label bold>Logs</Label>
|
|
163
|
+
<Box height={1} />
|
|
164
|
+
<ScrollView axis="vertical" flex={1}>
|
|
165
|
+
{props.items.map((item) => (
|
|
166
|
+
<Label color="value" key={item.timestamp}>{`[${item.level}] ${Bun.stripANSI(item.message)}`}</Label>
|
|
167
|
+
))}
|
|
168
|
+
</ScrollView>
|
|
169
|
+
<Box height={1} />
|
|
170
|
+
<Label color="mutedText">Enter or Esc to close</Label>
|
|
171
|
+
</Panel>
|
|
172
|
+
</Overlay>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
renderEditorScreen(props: EditorScreenProps): ReactNode {
|
|
177
|
+
return (
|
|
178
|
+
<Overlay>
|
|
179
|
+
<Panel flexDirection="column" padding={0} border={true}>
|
|
180
|
+
<Label bold>{props.label ?? props.fieldId}: </Label>
|
|
181
|
+
|
|
182
|
+
<Box flexDirection="column" gap={1}>
|
|
183
|
+
{props.editorType === "select" ? (
|
|
184
|
+
<Select
|
|
185
|
+
options={props.selectOptions ?? []}
|
|
186
|
+
value={props.valueString}
|
|
187
|
+
focused={true}
|
|
188
|
+
onChange={(value: string) => {
|
|
189
|
+
const index = (props.selectOptions ?? []).findIndex((o) => o.value === value);
|
|
190
|
+
props.onChangeSelectIndex?.(Math.max(0, index));
|
|
191
|
+
}}
|
|
192
|
+
onSubmit={() => props.onSubmit?.()}
|
|
193
|
+
/>
|
|
194
|
+
) : (
|
|
195
|
+
<TextInput
|
|
196
|
+
value={props.valueString}
|
|
197
|
+
placeholder=""
|
|
198
|
+
focused={true}
|
|
199
|
+
onChange={(next: string) => props.onChangeText?.(next)}
|
|
200
|
+
onSubmit={() => props.onSubmit?.()}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</Box>
|
|
204
|
+
|
|
205
|
+
<Label color="mutedText">Enter to submit Esc to cancel</Label>
|
|
206
|
+
</Panel>
|
|
207
|
+
</Overlay>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { Text } from "ink";
|
|
2
2
|
import type { ButtonProps } from "../../../semantic/types.ts";
|
|
3
3
|
|
|
4
|
-
export function Button({ label, selected }: ButtonProps) {
|
|
4
|
+
export function Button({ label, selected, onActivate }: ButtonProps) {
|
|
5
5
|
const prefix = selected ? "> " : " ";
|
|
6
6
|
return (
|
|
7
|
-
<Text
|
|
7
|
+
<Text
|
|
8
|
+
{...(onActivate
|
|
9
|
+
? {
|
|
10
|
+
onClick: () => {
|
|
11
|
+
onActivate();
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
: {})}
|
|
15
|
+
>
|
|
8
16
|
{prefix}
|
|
9
17
|
{label}
|
|
10
18
|
</Text>
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import type { OverlayProps } from "../../../semantic/
|
|
1
|
+
import type { OverlayProps } from "../../../semantic/layoutTypes.ts";
|
|
2
|
+
import { Box } from "ink";
|
|
2
3
|
|
|
3
4
|
export function Overlay({ children }: OverlayProps) {
|
|
4
|
-
return
|
|
5
|
+
return (
|
|
6
|
+
<Box flexDirection="column" flexGrow={1}>
|
|
7
|
+
<Box height={1}></Box>
|
|
8
|
+
{children}
|
|
9
|
+
</Box>
|
|
10
|
+
);
|
|
5
11
|
}
|
|
@@ -1,15 +1,36 @@
|
|
|
1
|
-
import { Text } from "ink";
|
|
2
|
-
import type { PanelProps } from "../../../semantic/
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { PanelProps } from "../../../semantic/layoutTypes.ts";
|
|
3
|
+
|
|
4
|
+
function normalizePadding(padding: PanelProps["padding"], opts: { dense: boolean }): { padding?: number } {
|
|
5
|
+
if (typeof padding === "number") {
|
|
6
|
+
return { padding };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (padding && typeof padding === "object") {
|
|
10
|
+
const top = padding.top ?? 0;
|
|
11
|
+
const right = padding.right ?? 0;
|
|
12
|
+
const bottom = padding.bottom ?? 0;
|
|
13
|
+
const left = padding.left ?? 0;
|
|
14
|
+
const fallback = opts.dense ? 0 : 1;
|
|
15
|
+
|
|
16
|
+
const pad = top === right && right === bottom && bottom === left ? top : fallback;
|
|
17
|
+
return { padding: pad };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { padding: opts.dense ? 0 : 1 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Panel({ title, children, padding, dense = false, flexDirection = "column" }: PanelProps) {
|
|
24
|
+
const resolvedPadding = normalizePadding(padding, { dense });
|
|
3
25
|
|
|
4
|
-
export function Panel({ title, children }: PanelProps) {
|
|
5
26
|
return (
|
|
6
|
-
|
|
27
|
+
<Box flexDirection={flexDirection} padding={resolvedPadding.padding}>
|
|
7
28
|
{title ? (
|
|
8
29
|
<Text bold>
|
|
9
30
|
{title}
|
|
10
31
|
</Text>
|
|
11
32
|
) : null}
|
|
12
33
|
{children}
|
|
13
|
-
|
|
34
|
+
</Box>
|
|
14
35
|
);
|
|
15
36
|
}
|
|
@@ -1,5 +1,46 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import type { ScrollViewProps, ScrollViewRef } from "../../../semantic/layoutTypes.ts";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Ink ScrollView - a simple container that provides scroll-like semantics.
|
|
7
|
+
*
|
|
8
|
+
* Note: Ink doesn't have native scroll container support like OpenTUI.
|
|
9
|
+
* This component provides the ScrollViewRef interface but scrolling is a no-op.
|
|
10
|
+
* Content will naturally flow/wrap in the terminal.
|
|
11
|
+
*/
|
|
12
|
+
export function ScrollView({
|
|
13
|
+
children,
|
|
14
|
+
scrollRef,
|
|
15
|
+
flex,
|
|
16
|
+
width,
|
|
17
|
+
height,
|
|
18
|
+
flexDirection = "column",
|
|
19
|
+
alignItems,
|
|
20
|
+
justifyContent,
|
|
21
|
+
gap,
|
|
22
|
+
}: ScrollViewProps) {
|
|
23
|
+
// Provide a dummy imperative API for compatibility
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (scrollRef) {
|
|
26
|
+
const noOpRef: ScrollViewRef = {
|
|
27
|
+
scrollToIndex: () => {},
|
|
28
|
+
};
|
|
29
|
+
scrollRef(noOpRef);
|
|
30
|
+
}
|
|
31
|
+
}, [scrollRef]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box
|
|
35
|
+
flexGrow={flex}
|
|
36
|
+
width={width as any}
|
|
37
|
+
height={height as any}
|
|
38
|
+
flexDirection={flexDirection}
|
|
39
|
+
alignItems={alignItems}
|
|
40
|
+
justifyContent={justifyContent}
|
|
41
|
+
gap={gap}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
5
46
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { SpinnerProps } from "../../../semantic/types.ts";
|
|
2
2
|
|
|
3
|
-
export function Spinner(
|
|
4
|
-
|
|
3
|
+
export function Spinner({ active }: SpinnerProps) {
|
|
4
|
+
//const { frame } = useSpinner(active);
|
|
5
|
+
|
|
6
|
+
if (!active) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return <></>;
|
|
5
11
|
}
|
|
@@ -5,10 +5,7 @@ import type { KeyboardAdapter, KeyboardEvent, KeyHandler } from "../types.ts";
|
|
|
5
5
|
function normalizeKeyName(input: string, key: Key): KeyboardEvent {
|
|
6
6
|
const event: KeyboardEvent = {
|
|
7
7
|
name: input,
|
|
8
|
-
sequence: input,
|
|
9
8
|
ctrl: Boolean(key.ctrl),
|
|
10
|
-
shift: Boolean(key.shift),
|
|
11
|
-
meta: Boolean(key.meta),
|
|
12
9
|
};
|
|
13
10
|
|
|
14
11
|
if (key.return) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import type { Command } from "../../../../core/command.ts";
|
|
3
|
+
import { MenuItem } from "../components/MenuItem.tsx";
|
|
4
|
+
import { Panel } from "../components/Panel.tsx";
|
|
5
|
+
|
|
6
|
+
interface CommandItem {
|
|
7
|
+
command: Command;
|
|
8
|
+
label?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CommandSelectorProps {
|
|
13
|
+
commands: CommandItem[];
|
|
14
|
+
selectedIndex: number;
|
|
15
|
+
onSelect: (command: Command) => void;
|
|
16
|
+
breadcrumb?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CommandSelector({ commands, selectedIndex, onSelect, breadcrumb }: CommandSelectorProps) {
|
|
20
|
+
const title = breadcrumb?.length ? `Select Command (${breadcrumb.join(" > ")})` : "Select Command";
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Box flexDirection="column" flexGrow={1} >
|
|
24
|
+
<Panel flexDirection="column" title={title} padding={0} width={60} focused>
|
|
25
|
+
<Box flexDirection="column">
|
|
26
|
+
{commands.map((item, idx) => {
|
|
27
|
+
const isSelected = idx === selectedIndex;
|
|
28
|
+
const label = item.label ?? item.command.displayName ?? item.command.name;
|
|
29
|
+
const description = item.description ?? item.command.description;
|
|
30
|
+
const modeIndicator = getModeIndicator(item.command);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<MenuItem
|
|
34
|
+
key={item.command.name}
|
|
35
|
+
label={label}
|
|
36
|
+
description={description}
|
|
37
|
+
suffix={modeIndicator}
|
|
38
|
+
selected={isSelected}
|
|
39
|
+
onActivate={() => onSelect(item.command)}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</Box>
|
|
44
|
+
</Panel>
|
|
45
|
+
</Box>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getModeIndicator(command: Command): string {
|
|
50
|
+
const navigableSubCommands = command.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
|
|
51
|
+
if (navigableSubCommands.length > 0) {
|
|
52
|
+
return ">";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import { Field } from "../components/Field.tsx";
|
|
4
|
+
import { MenuButton } from "../components/MenuButton.tsx";
|
|
5
|
+
import { Panel } from "../components/Panel.tsx";
|
|
6
|
+
import { ScrollView } from "../components/ScrollView.tsx";
|
|
7
|
+
import type { FieldConfig } from "../../../semantic/types.ts";
|
|
8
|
+
import type { ScrollViewRef } from "../../../semantic/layoutTypes.ts";
|
|
9
|
+
|
|
10
|
+
interface ConfigFormProps {
|
|
11
|
+
title: string;
|
|
12
|
+
fieldConfigs: FieldConfig[];
|
|
13
|
+
values: Record<string, unknown>;
|
|
14
|
+
selectedIndex: number;
|
|
15
|
+
focused: boolean;
|
|
16
|
+
getDisplayValue?: (key: string, value: unknown, type: string) => string;
|
|
17
|
+
actionButton: ReactNode;
|
|
18
|
+
additionalButtons?: { label: string; onPress: () => void }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultGetDisplayValue(_key: string, value: unknown, type: string): string {
|
|
22
|
+
if (type === "boolean") {
|
|
23
|
+
return value ? "True" : "False";
|
|
24
|
+
}
|
|
25
|
+
const strValue = String(value ?? "");
|
|
26
|
+
if (strValue === "") {
|
|
27
|
+
return "(empty)";
|
|
28
|
+
}
|
|
29
|
+
return strValue.length > 60 ? strValue.substring(0, 57) + "..." : strValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ConfigForm({
|
|
33
|
+
title,
|
|
34
|
+
fieldConfigs,
|
|
35
|
+
values,
|
|
36
|
+
selectedIndex,
|
|
37
|
+
focused,
|
|
38
|
+
getDisplayValue = defaultGetDisplayValue,
|
|
39
|
+
actionButton,
|
|
40
|
+
additionalButtons = [],
|
|
41
|
+
}: ConfigFormProps) {
|
|
42
|
+
const scrollViewRef = useRef<ScrollViewRef | null>(null);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
scrollViewRef.current?.scrollToIndex(selectedIndex);
|
|
46
|
+
}, [selectedIndex]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Panel title={title} focused={focused} flex={1} padding={0} flexDirection="column">
|
|
50
|
+
<ScrollView
|
|
51
|
+
axis="vertical"
|
|
52
|
+
flex={1}
|
|
53
|
+
scrollRef={(ref) => {
|
|
54
|
+
scrollViewRef.current = ref;
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<Box flexDirection="column" gap={0}>
|
|
58
|
+
{fieldConfigs.map((field, idx) => {
|
|
59
|
+
const isSelected = idx === selectedIndex;
|
|
60
|
+
const displayValue = getDisplayValue(field.key, values[field.key], field.type);
|
|
61
|
+
|
|
62
|
+
return <Field key={field.key} label={field.label} value={displayValue} selected={isSelected} />;
|
|
63
|
+
})}
|
|
64
|
+
|
|
65
|
+
<Box height={1} />
|
|
66
|
+
|
|
67
|
+
{additionalButtons.map((btn, idx) => {
|
|
68
|
+
const buttonSelectedIndex = fieldConfigs.length + idx;
|
|
69
|
+
return <MenuButton key={btn.label} label={btn.label} selected={selectedIndex === buttonSelectedIndex} />;
|
|
70
|
+
})}
|
|
71
|
+
|
|
72
|
+
{actionButton}
|
|
73
|
+
</Box>
|
|
74
|
+
</ScrollView>
|
|
75
|
+
</Panel>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import { Label } from "../components/Label.tsx";
|
|
3
|
+
|
|
4
|
+
interface HeaderProps {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
breadcrumb?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Header({ name, version, breadcrumb }: HeaderProps) {
|
|
11
|
+
const breadcrumbStr = breadcrumb?.length ? ` 3 ${breadcrumb.join(" 3 ")}` : "";
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
15
|
+
<Box flexDirection="row" justifyContent="space-between">
|
|
16
|
+
<Label color="mutedText" bold>
|
|
17
|
+
{name}
|
|
18
|
+
{breadcrumbStr}
|
|
19
|
+
</Label>
|
|
20
|
+
<Label color="mutedText">v{version}</Label>
|
|
21
|
+
</Box>
|
|
22
|
+
<Box height={1} />
|
|
23
|
+
</Box>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Box } from "ink";
|
|
2
|
+
import { tokenizeJsonValue } from "../../../utils/jsonTokenizer.ts";
|
|
3
|
+
import { CodeHighlight } from "../components/CodeHighlight.tsx";
|
|
4
|
+
|
|
5
|
+
export interface JsonHighlightProps {
|
|
6
|
+
value: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function JsonHighlight({ value }: JsonHighlightProps) {
|
|
10
|
+
const lines = tokenizeJsonValue(value);
|
|
11
|
+
return (
|
|
12
|
+
<Box flexDirection="column" gap={0}>
|
|
13
|
+
{lines.map((tokens, lineIdx) => (
|
|
14
|
+
<CodeHighlight
|
|
15
|
+
key={`json-${lineIdx}`}
|
|
16
|
+
tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
|
|
17
|
+
/>
|
|
18
|
+
))}
|
|
19
|
+
</Box>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Box } from "ink";
|
|
3
|
+
import type { CommandResult } from "../../../../core/command.ts";
|
|
4
|
+
|
|
5
|
+
// Platform-native components (Ink)
|
|
6
|
+
import { Panel } from "../components/Panel.tsx";
|
|
7
|
+
import { ScrollView } from "../components/ScrollView.tsx";
|
|
8
|
+
import { Label } from "../components/Label.tsx";
|
|
9
|
+
|
|
10
|
+
// Adapter-local JSON highlighting
|
|
11
|
+
import { JsonHighlight } from "./JsonHighlight.tsx";
|
|
12
|
+
|
|
13
|
+
interface ResultsPanelProps {
|
|
14
|
+
result: CommandResult | null;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
focused: boolean;
|
|
17
|
+
renderResult?: (result: CommandResult) => ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ResultsPanel({ result, error, focused }: ResultsPanelProps) {
|
|
21
|
+
let content: ReactNode;
|
|
22
|
+
|
|
23
|
+
if (error) {
|
|
24
|
+
content = (
|
|
25
|
+
<Box flexDirection="column" gap={1}>
|
|
26
|
+
<Label color="error" bold>
|
|
27
|
+
Error
|
|
28
|
+
</Label>
|
|
29
|
+
<Label color="error">{error.message}</Label>
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
} else if (result) {
|
|
33
|
+
// for now we ignore renderResult in ink version
|
|
34
|
+
|
|
35
|
+
content = (
|
|
36
|
+
<Box flexDirection="column" gap={1}>
|
|
37
|
+
{result.message && <Label color={result.success ? "success" : "error"}>{result.message}</Label>}
|
|
38
|
+
{result.data !== undefined && result.data !== null && (
|
|
39
|
+
typeof result.data === "object"
|
|
40
|
+
? <JsonHighlight value={result.data} />
|
|
41
|
+
: <Label color="value">{String(result.data)}</Label>
|
|
42
|
+
)}
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
} else {
|
|
47
|
+
content = <Label color="mutedText">No results yet...</Label>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Panel title="Results" focused={focused} flex={1} padding={1} flexDirection="column">
|
|
52
|
+
<ScrollView axis="vertical" flex={1} focused={focused}>
|
|
53
|
+
<Box flexDirection="column">{content}</Box>
|
|
54
|
+
</ScrollView>
|
|
55
|
+
</Panel>
|
|
56
|
+
);
|
|
57
|
+
}
|