@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
@@ -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/types.ts";
1
+ import type { OverlayProps } from "../../../semantic/layoutTypes.ts";
2
+ import { Box } from "ink";
2
3
 
3
4
  export function Overlay({ children }: OverlayProps) {
4
- return <>{children}</>;
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/types.ts";
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 type { ScrollViewProps } from "../../../semantic/types.ts";
1
+ import { useEffect } from "react";
2
+ import { Box } from "ink";
3
+ import type { ScrollViewProps, ScrollViewRef } from "../../../semantic/layoutTypes.ts";
2
4
 
3
- export function ScrollView({ children }: ScrollViewProps) {
4
- return <>{children}</>;
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(_: SpinnerProps) {
4
- return null;
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
+ }