@pablozaiden/terminatui 0.3.0 → 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 (111) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
  3. package/src/__tests__/schemaToFields.test.ts +0 -4
  4. package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
  5. package/src/index.ts +2 -2
  6. package/src/tui/TuiApplication.tsx +0 -4
  7. package/src/tui/TuiRoot.tsx +58 -102
  8. package/src/tui/actions.ts +4 -0
  9. package/src/tui/adapters/ink/InkRenderer.tsx +191 -45
  10. package/src/tui/adapters/ink/SemanticInkRenderer.tsx +210 -0
  11. package/src/tui/adapters/ink/components/Button.tsx +10 -2
  12. package/src/tui/adapters/ink/components/Overlay.tsx +8 -2
  13. package/src/tui/adapters/ink/components/Panel.tsx +26 -5
  14. package/src/tui/adapters/ink/components/ScrollView.tsx +44 -3
  15. package/src/tui/adapters/ink/components/Spinner.tsx +8 -2
  16. package/src/tui/adapters/ink/keyboard.ts +0 -3
  17. package/src/tui/adapters/ink/ui/CommandSelector.tsx +56 -0
  18. package/src/tui/adapters/ink/ui/ConfigForm.tsx +77 -0
  19. package/src/tui/adapters/ink/ui/Header.tsx +25 -0
  20. package/src/tui/adapters/ink/ui/JsonHighlight.tsx +21 -0
  21. package/src/tui/adapters/ink/ui/ResultsPanel.tsx +57 -0
  22. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +190 -43
  23. package/src/tui/adapters/opentui/SemanticOpenTuiRenderer.tsx +192 -0
  24. package/src/tui/adapters/opentui/components/Label.tsx +2 -2
  25. package/src/tui/adapters/opentui/components/Overlay.tsx +12 -3
  26. package/src/tui/adapters/opentui/components/Panel.tsx +11 -1
  27. package/src/tui/adapters/opentui/components/ScrollView.tsx +1 -8
  28. package/src/tui/adapters/opentui/components/Spinner.tsx +1 -1
  29. package/src/tui/adapters/opentui/keyboard.ts +0 -3
  30. package/src/tui/adapters/opentui/ui/CommandSelector.tsx +55 -0
  31. package/src/tui/adapters/opentui/ui/ConfigForm.tsx +74 -0
  32. package/src/tui/adapters/opentui/ui/Header.tsx +24 -0
  33. package/src/tui/adapters/opentui/ui/JsonHighlight.tsx +20 -0
  34. package/src/tui/adapters/opentui/ui/LogsPanel.tsx +44 -0
  35. package/src/tui/adapters/opentui/ui/ResultsPanel.tsx +62 -0
  36. package/src/tui/adapters/shared/TerminalClipboard.ts +65 -0
  37. package/src/tui/adapters/{opentui/hooks → shared}/useSpinner.ts +5 -1
  38. package/src/tui/adapters/types.ts +25 -46
  39. package/src/tui/components/JsonHighlight.tsx +41 -111
  40. package/src/tui/context/ActionContext.tsx +51 -0
  41. package/src/tui/context/ExecutorContext.tsx +7 -1
  42. package/src/tui/context/NavigationContext.tsx +20 -4
  43. package/src/tui/controllers/CommandBrowserController.tsx +100 -0
  44. package/src/tui/controllers/ConfigController.tsx +183 -0
  45. package/src/tui/controllers/EditorController.tsx +169 -0
  46. package/src/tui/controllers/LogsController.tsx +48 -0
  47. package/src/tui/controllers/OutcomeController.tsx +110 -0
  48. package/src/tui/driver/TuiDriver.tsx +148 -0
  49. package/src/tui/driver/context/TuiDriverContext.tsx +44 -0
  50. package/src/tui/driver/types.ts +72 -0
  51. package/src/tui/semantic/AppShell.tsx +30 -0
  52. package/src/tui/semantic/CommandBrowserScreen.tsx +16 -0
  53. package/src/tui/semantic/ConfigScreen.tsx +23 -0
  54. package/src/tui/semantic/EditorScreen.tsx +20 -0
  55. package/src/tui/semantic/LogsScreen.tsx +9 -0
  56. package/src/tui/semantic/RunningScreen.tsx +17 -0
  57. package/src/tui/semantic/layoutTypes.ts +72 -0
  58. package/src/tui/semantic/render.tsx +44 -0
  59. package/src/tui/semantic/types.ts +31 -98
  60. package/src/tui/utils/jsonTokenizer.ts +98 -0
  61. package/src/tui/utils/schemaToFields.ts +1 -25
  62. package/src/tui/adapters/ink/components/Code.tsx +0 -6
  63. package/src/tui/adapters/ink/components/Container.tsx +0 -5
  64. package/src/tui/adapters/ink/components/Spacer.tsx +0 -15
  65. package/src/tui/adapters/ink/components/Value.tsx +0 -7
  66. package/src/tui/adapters/opentui/components/Code.tsx +0 -12
  67. package/src/tui/adapters/opentui/components/Container.tsx +0 -56
  68. package/src/tui/adapters/opentui/components/Spacer.tsx +0 -5
  69. package/src/tui/adapters/opentui/components/Value.tsx +0 -13
  70. package/src/tui/components/ActionButton.tsx +0 -0
  71. package/src/tui/components/CommandSelector.tsx +0 -119
  72. package/src/tui/components/ConfigForm.tsx +0 -174
  73. package/src/tui/components/FieldRow.tsx +0 -0
  74. package/src/tui/components/Header.tsx +0 -32
  75. package/src/tui/components/ModalBase.tsx +0 -38
  76. package/src/tui/components/ResultsPanel.tsx +0 -84
  77. package/src/tui/components/StatusBar.tsx +0 -44
  78. package/src/tui/components/logColors.ts +0 -12
  79. package/src/tui/components/types.ts +0 -30
  80. package/src/tui/context/ClipboardContext.tsx +0 -87
  81. package/src/tui/context/KeyboardContext.tsx +0 -132
  82. package/src/tui/hooks/useActiveKeyHandler.ts +0 -75
  83. package/src/tui/hooks/useClipboard.ts +0 -81
  84. package/src/tui/hooks/useClipboardProvider.ts +0 -42
  85. package/src/tui/hooks/useGlobalKeyHandler.ts +0 -54
  86. package/src/tui/modals/CliModal.tsx +0 -82
  87. package/src/tui/modals/EditorModal.tsx +0 -207
  88. package/src/tui/modals/LogsModal.tsx +0 -98
  89. package/src/tui/registry.ts +0 -102
  90. package/src/tui/screens/CommandSelectScreen.tsx +0 -162
  91. package/src/tui/screens/ConfigScreen.tsx +0 -165
  92. package/src/tui/screens/ErrorScreen.tsx +0 -58
  93. package/src/tui/screens/ResultsScreen.tsx +0 -68
  94. package/src/tui/screens/RunningScreen.tsx +0 -72
  95. package/src/tui/screens/ScreenBase.ts +0 -6
  96. package/src/tui/semantic/Button.tsx +0 -7
  97. package/src/tui/semantic/Code.tsx +0 -7
  98. package/src/tui/semantic/CodeHighlight.tsx +0 -7
  99. package/src/tui/semantic/Container.tsx +0 -7
  100. package/src/tui/semantic/Field.tsx +0 -7
  101. package/src/tui/semantic/Label.tsx +0 -7
  102. package/src/tui/semantic/MenuButton.tsx +0 -7
  103. package/src/tui/semantic/MenuItem.tsx +0 -7
  104. package/src/tui/semantic/Overlay.tsx +0 -7
  105. package/src/tui/semantic/Panel.tsx +0 -7
  106. package/src/tui/semantic/ScrollView.tsx +0 -9
  107. package/src/tui/semantic/Select.tsx +0 -7
  108. package/src/tui/semantic/Spacer.tsx +0 -7
  109. package/src/tui/semantic/Spinner.tsx +0 -7
  110. package/src/tui/semantic/TextInput.tsx +0 -7
  111. package/src/tui/semantic/Value.tsx +0 -7
@@ -0,0 +1,98 @@
1
+ import type { CodeTokenType } from "../semantic/types.ts";
2
+
3
+ /**
4
+ * A single token in a JSON line.
5
+ */
6
+ export type JsonToken = { type: Exclude<CodeTokenType, "unknown">; value: string };
7
+
8
+ /**
9
+ * A line of JSON tokens.
10
+ */
11
+ export type JsonLineTokens = JsonToken[];
12
+
13
+ /**
14
+ * Tokenize a JavaScript value into lines of syntax-highlighted JSON tokens.
15
+ *
16
+ * This is a pure behavioral helper with no React dependencies.
17
+ * Adapters can use this to build their own rendering.
18
+ */
19
+ export function tokenizeJsonValue(value: unknown, indent = 0): JsonLineTokens[] {
20
+ const pad = " ".repeat(indent);
21
+ const padToken = (): JsonToken => ({ type: "punctuation", value: pad });
22
+
23
+ if (value === null) {
24
+ return [[padToken(), { type: "null", value: "null" }]];
25
+ }
26
+ if (typeof value === "boolean") {
27
+ return [[padToken(), { type: "boolean", value: String(value) }]];
28
+ }
29
+ if (typeof value === "number") {
30
+ return [[padToken(), { type: "number", value: String(value) }]];
31
+ }
32
+ if (typeof value === "string") {
33
+ return [[padToken(), { type: "string", value: JSON.stringify(value) }]];
34
+ }
35
+ if (Array.isArray(value)) {
36
+ if (value.length === 0) {
37
+ return [[padToken(), { type: "punctuation", value: "[]" }]];
38
+ }
39
+ const lines: JsonLineTokens[] = [[padToken(), { type: "punctuation", value: "[" }]];
40
+ value.forEach((item, idx) => {
41
+ const itemLines = tokenizeJsonValue(item, indent + 1);
42
+ const isLast = idx === value.length - 1;
43
+ itemLines.forEach((line, lineIdx) => {
44
+ if (lineIdx === itemLines.length - 1 && !isLast) {
45
+ lines.push([...line, { type: "punctuation", value: "," }]);
46
+ } else {
47
+ lines.push(line);
48
+ }
49
+ });
50
+ });
51
+ lines.push([padToken(), { type: "punctuation", value: "]" }]);
52
+ return lines;
53
+ }
54
+ if (typeof value === "object") {
55
+ const entries = Object.entries(value);
56
+ if (entries.length === 0) {
57
+ return [[padToken(), { type: "punctuation", value: "{}" }]];
58
+ }
59
+ const lines: JsonLineTokens[] = [[padToken(), { type: "punctuation", value: "{" }]];
60
+ const innerPad = " ".repeat(indent + 1);
61
+
62
+ entries.forEach(([key, val], idx) => {
63
+ const valLines = tokenizeJsonValue(val, indent + 1);
64
+ const isLast = idx === entries.length - 1;
65
+
66
+ // First value line - prepend key
67
+ const firstValLine = valLines[0] ?? [];
68
+ // Remove the padding from value's first line (we'll add our own with the key)
69
+ const valTokens = firstValLine.filter((t) => t.value !== " ".repeat(indent + 1));
70
+
71
+ const keyLine: JsonLineTokens = [
72
+ { type: "punctuation", value: innerPad },
73
+ { type: "key", value: `"${key}"` },
74
+ { type: "punctuation", value: ": " },
75
+ ...valTokens,
76
+ ];
77
+
78
+ if (valLines.length === 1) {
79
+ // Single line value
80
+ if (!isLast) keyLine.push({ type: "punctuation", value: "," });
81
+ lines.push(keyLine);
82
+ } else {
83
+ // Multi-line value
84
+ lines.push(keyLine);
85
+ valLines.slice(1, -1).forEach((line) => lines.push(line));
86
+ const lastLine = valLines[valLines.length - 1] ?? [];
87
+ if (!isLast) {
88
+ lines.push([...lastLine, { type: "punctuation", value: "," }]);
89
+ } else {
90
+ lines.push(lastLine);
91
+ }
92
+ }
93
+ });
94
+ lines.push([padToken(), { type: "punctuation", value: "}" }]);
95
+ return lines;
96
+ }
97
+ return [];
98
+ }
@@ -1,5 +1,5 @@
1
1
  import type { OptionSchema, OptionDef } from "../../types/command.ts";
2
- import type { FieldConfig, FieldType, FieldOption } from "../components/types.ts";
2
+ import type { FieldConfig, FieldType, FieldOption } from "../semantic/types.ts";
3
3
 
4
4
  /**
5
5
  * Convert an option type to a field type.
@@ -75,8 +75,6 @@ export function schemaToFieldConfigs(schema: OptionSchema): FieldConfig[] {
75
75
  label: def.label ?? keyToLabel(key),
76
76
  type: optionTypeToFieldType(def),
77
77
  options: createFieldOptions(def),
78
- placeholder: def.placeholder,
79
- group: def.group,
80
78
  };
81
79
 
82
80
  fields.push(fieldConfig);
@@ -92,28 +90,6 @@ export function schemaToFieldConfigs(schema: OptionSchema): FieldConfig[] {
92
90
  return fields;
93
91
  }
94
92
 
95
- /**
96
- * Group field configs by their group property.
97
- *
98
- * @param fields - Field configs to group
99
- * @returns Map of group name to field configs
100
- */
101
- export function groupFieldConfigs(
102
- fields: FieldConfig[]
103
- ): Map<string | undefined, FieldConfig[]> {
104
- const groups = new Map<string | undefined, FieldConfig[]>();
105
-
106
- for (const field of fields) {
107
- const group = field.group;
108
- if (!groups.has(group)) {
109
- groups.set(group, []);
110
- }
111
- groups.get(group)!.push(field);
112
- }
113
-
114
- return groups;
115
- }
116
-
117
93
  /**
118
94
  * Get display value for a field.
119
95
  *
@@ -1,6 +0,0 @@
1
- import { Text } from "ink";
2
- import type { CodeProps } from "../../../semantic/types.ts";
3
-
4
- export function Code({ children }: CodeProps) {
5
- return <Text color="gray">{children}</Text>;
6
- }
@@ -1,5 +0,0 @@
1
- import type { ContainerProps } from "../../../semantic/types.ts";
2
-
3
- export function Container({ children }: ContainerProps) {
4
- return <>{children}</>;
5
- }
@@ -1,15 +0,0 @@
1
- import { Text } from "ink";
2
- import type { SpacerProps } from "../../../semantic/types.ts";
3
-
4
- export function Spacer({ size, axis }: SpacerProps) {
5
- if (axis === "horizontal") {
6
- return <Text>{" ".repeat(size)}</Text>;
7
- }
8
- return (
9
- <>
10
- {Array.from({ length: size }).map((_, idx) => (
11
- <Text key={idx} />
12
- ))}
13
- </>
14
- );
15
- }
@@ -1,7 +0,0 @@
1
- import { Text } from "ink";
2
- import type { ValueProps } from "../../../semantic/types.ts";
3
- import { toPlainText } from "../utils.ts";
4
-
5
- export function Value({ children }: ValueProps) {
6
- return <Text color="magenta">{toPlainText(children)}</Text>;
7
- }
@@ -1,12 +0,0 @@
1
- import type { CodeProps } from "../../../semantic/types.ts";
2
- import { SemanticColors } from "../../../theme.ts";
3
-
4
- export function Code({ color = "code", children }: CodeProps) {
5
- const fg = SemanticColors[color] ?? SemanticColors.code;
6
-
7
- return (
8
- <text fg={fg}>
9
- {children}
10
- </text>
11
- );
12
- }
@@ -1,56 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import type { ContainerProps, Spacing } from "../../../semantic/types.ts";
3
-
4
- function normalizePadding(padding: number | Spacing | undefined):
5
- | { padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number }
6
- | undefined {
7
- if (padding === undefined) {
8
- return undefined;
9
- }
10
-
11
- if (typeof padding === "number") {
12
- return { padding };
13
- }
14
-
15
- return {
16
- paddingTop: padding.top ?? 0,
17
- paddingRight: padding.right ?? 0,
18
- paddingBottom: padding.bottom ?? 0,
19
- paddingLeft: padding.left ?? 0,
20
- };
21
- }
22
-
23
- export function Container({
24
- children,
25
- flex,
26
- width,
27
- height,
28
- flexDirection,
29
- alignItems,
30
- justifyContent,
31
- gap,
32
- padding,
33
- noShrink,
34
- }: ContainerProps & { children?: ReactNode }) {
35
- const resolvedPadding = normalizePadding(padding);
36
-
37
- return (
38
- <box
39
- flexGrow={flex}
40
- flexShrink={noShrink ? 0 : flex === undefined ? undefined : 1}
41
- width={width as any}
42
- height={height as any}
43
- flexDirection={flexDirection as any}
44
- alignItems={alignItems as any}
45
- justifyContent={justifyContent as any}
46
- gap={gap}
47
- padding={resolvedPadding?.padding}
48
- paddingTop={resolvedPadding?.paddingTop}
49
- paddingRight={resolvedPadding?.paddingRight}
50
- paddingBottom={resolvedPadding?.paddingBottom}
51
- paddingLeft={resolvedPadding?.paddingLeft}
52
- >
53
- {children}
54
- </box>
55
- );
56
- }
@@ -1,5 +0,0 @@
1
- import type { SpacerProps } from "../../../semantic/types.ts";
2
-
3
- export function Spacer({ size, axis = "vertical" }: SpacerProps) {
4
- return axis === "horizontal" ? <box width={size} flexShrink={0} /> : <box height={size} flexShrink={0} />;
5
- }
@@ -1,13 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import type { ValueProps } from "../../../semantic/types.ts";
3
- import { SemanticColors } from "../../../theme.ts";
4
-
5
- export function Value({ color = "value", truncate, children }: ValueProps & { children: ReactNode }) {
6
- const fg = SemanticColors[color] ?? SemanticColors.value;
7
-
8
- return (
9
- <text fg={fg} {...({ truncate })}>
10
- {children}
11
- </text>
12
- );
13
- }
File without changes
@@ -1,119 +0,0 @@
1
- import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
2
- import type { Command } from "../../core/command.ts";
3
- import { MenuItem } from "../semantic/MenuItem.tsx";
4
- import { Container } from "../semantic/Container.tsx";
5
- import { Panel } from "../semantic/Panel.tsx";
6
- import { Label } from "../semantic/Label.tsx";
7
-
8
- interface CommandItem {
9
- /** The command object */
10
- command: Command;
11
- /** Display label (defaults to command name) */
12
- label?: string;
13
- /** Description (defaults to command description) */
14
- description?: string;
15
- }
16
-
17
- interface CommandSelectorProps {
18
- /** Commands to display */
19
- commands: CommandItem[];
20
- /** Currently selected index */
21
- selectedIndex: number;
22
- /** Called when selection changes */
23
- onSelectionChange: (index: number) => void;
24
- /** Called when a command is selected */
25
- onSelect: (command: Command) => void;
26
- /** Breadcrumb path for nested commands */
27
- breadcrumb?: string[];
28
- }
29
-
30
- /**
31
- * Command selection menu.
32
- */
33
- export function CommandSelector({
34
- commands,
35
- selectedIndex,
36
- onSelectionChange,
37
- onSelect,
38
- breadcrumb,
39
- }: CommandSelectorProps) {
40
- // Active keyboard handler for navigation
41
- useActiveKeyHandler((key) => {
42
- // Arrow key navigation
43
- if (key.name === "down") {
44
- const newIndex = Math.min(selectedIndex + 1, commands.length - 1);
45
- onSelectionChange(newIndex);
46
- return true;
47
- }
48
-
49
- if (key.name === "up") {
50
- const newIndex = Math.max(selectedIndex - 1, 0);
51
- onSelectionChange(newIndex);
52
- return true;
53
- }
54
-
55
- // Enter to select command
56
- if (key.name === "return" || key.name === "enter") {
57
- const selected = commands[selectedIndex];
58
- if (selected) {
59
- onSelect(selected.command);
60
- }
61
- return true;
62
- }
63
-
64
- return false;
65
- });
66
-
67
- const title = breadcrumb?.length
68
- ? `Select Command (${breadcrumb.join(" › ")})`
69
- : "Select Command";
70
-
71
- return (
72
- <Container flexDirection="column" flex={1} justifyContent="center" alignItems="center" gap={1}>
73
- <Panel
74
- flexDirection="column"
75
- title={title}
76
- padding={undefined}
77
- width={60}
78
- focused
79
- >
80
- <Container flexDirection="column" gap={1}>
81
- {commands.map((item, idx) => {
82
- const isSelected = idx === selectedIndex;
83
- const label = item.label ?? item.command.displayName ?? item.command.name;
84
- const description = item.description ?? item.command.description;
85
-
86
- const modeIndicator = getModeIndicator(item.command);
87
-
88
- return (
89
- <MenuItem
90
- key={item.command.name}
91
- label={label}
92
- description={description}
93
- suffix={modeIndicator}
94
- selected={isSelected}
95
- onActivate={() => onSelect(item.command)}
96
- />
97
- );
98
- })}
99
- </Container>
100
- </Panel>
101
-
102
- <Label color="mutedText">↑/↓ Navigate • Enter Select • Esc {breadcrumb?.length ? "Back" : "Exit"}</Label>
103
- </Container>
104
- );
105
- }
106
-
107
- /**
108
- * Get mode indicator for a command (e.g., "[cli]", "[tui]", "→" for subcommands).
109
- */
110
- function getModeIndicator(command: Command): string {
111
- // Show navigation indicator for container commands with navigable subcommands
112
- // (excluding commands that don't support TUI)
113
- const navigableSubCommands = command.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
114
- if (navigableSubCommands.length > 0) {
115
- return "→";
116
- }
117
-
118
- return "";
119
- }
@@ -1,174 +0,0 @@
1
- import { useRef, useEffect, type ReactNode } from "react";
2
- import { Field } from "../semantic/Field.tsx";
3
- import { MenuButton } from "../semantic/MenuButton.tsx";
4
- import { Panel } from "../semantic/Panel.tsx";
5
- import { ScrollView, type ScrollViewRef } from "../semantic/ScrollView.tsx";
6
- import { Container } from "../semantic/Container.tsx";
7
- import { useActiveKeyHandler } from "../hooks/useActiveKeyHandler.ts";
8
- import type { KeyboardEvent } from "../adapters/types.ts";
9
- import type { FieldConfig } from "./types.ts";
10
-
11
- interface ConfigFormProps {
12
- /** Title for the form border */
13
- title: string;
14
- /** Field configurations */
15
- fieldConfigs: FieldConfig[];
16
- /** Current values */
17
- values: Record<string, unknown>;
18
- /** Currently selected index */
19
- selectedIndex: number;
20
- /** Whether the form is focused */
21
- focused: boolean;
22
- /** Called when selection changes */
23
- onSelectionChange: (index: number) => void;
24
- /** Called when a field should be edited */
25
- onEditField: (fieldKey: string) => void;
26
- /** Called when the action button is pressed */
27
- onAction: () => void;
28
- /** Function to get display value for a field */
29
- getDisplayValue?: (key: string, value: unknown, type: string) => string;
30
- /** The action button component */
31
- actionButton: ReactNode;
32
- /** Optional additional buttons rendered before the main action button */
33
- additionalButtons?: { label: string; onPress: () => void }[];
34
- /** Optional handler for additional keys (called before default handling) */
35
- onKeyDown?: (event: KeyboardEvent) => boolean;
36
- }
37
-
38
- /**
39
- * Default display value formatter.
40
- */
41
- function defaultGetDisplayValue(_key: string, value: unknown, type: string): string {
42
- if (type === "boolean") {
43
- return value ? "True" : "False";
44
- }
45
- const strValue = String(value ?? "");
46
- if (strValue === "") {
47
- return "(empty)";
48
- }
49
- return strValue.length > 60 ? strValue.substring(0, 57) + "..." : strValue;
50
- }
51
-
52
- /**
53
- * Generic config form that renders fields from a schema.
54
- */
55
- export function ConfigForm({
56
- title,
57
- fieldConfigs,
58
- values,
59
- selectedIndex,
60
- focused,
61
- onSelectionChange,
62
- onEditField,
63
- onAction,
64
- getDisplayValue = defaultGetDisplayValue,
65
- actionButton,
66
- additionalButtons = [],
67
- onKeyDown,
68
- }: ConfigFormProps) {
69
- const scrollViewRef = useRef<ScrollViewRef | null>(null);
70
- const totalItems = fieldConfigs.length + additionalButtons.length + 1; // fields + additional buttons + action button
71
-
72
- // Auto-scroll to keep selected item visible
73
- useEffect(() => {
74
- scrollViewRef.current?.scrollToIndex(selectedIndex);
75
- }, [selectedIndex]);
76
-
77
- // Handle keyboard events (only when focused)
78
- useActiveKeyHandler(
79
- (event: KeyboardEvent) => {
80
- // Let parent handle first if provided
81
- if (onKeyDown?.(event)) {
82
- return true;
83
- }
84
-
85
- const key = event;
86
-
87
- // Arrow key navigation
88
- if (key.name === "down") {
89
- const newIndex = Math.min(selectedIndex + 1, totalItems - 1);
90
- onSelectionChange(newIndex);
91
- return true;
92
- }
93
-
94
- if (key.name === "up") {
95
- const newIndex = Math.max(selectedIndex - 1, 0);
96
- onSelectionChange(newIndex);
97
- return true;
98
- }
99
-
100
- // Enter to edit field, press additional button, or run action
101
- if (key.name === "return" || key.name === "enter") {
102
- if (selectedIndex < fieldConfigs.length) {
103
- // It's a field
104
- const fieldConfig = fieldConfigs[selectedIndex];
105
- if (fieldConfig) {
106
- onEditField(fieldConfig.key);
107
- }
108
- } else if (selectedIndex < fieldConfigs.length + additionalButtons.length) {
109
- // It's an additional button
110
- const buttonIndex = selectedIndex - fieldConfigs.length;
111
- additionalButtons[buttonIndex]?.onPress();
112
- } else {
113
- // It's the main action button
114
- onAction();
115
- }
116
- return true;
117
- }
118
-
119
- return false;
120
- },
121
- { enabled: focused }
122
- );
123
-
124
- return (
125
- <Panel
126
- title={title}
127
- focused={focused}
128
- flex={1}
129
- padding={1}
130
- flexDirection="column"
131
- >
132
- <ScrollView
133
- axis="vertical"
134
- flex={1}
135
- scrollRef={(ref) => {
136
- scrollViewRef.current = ref;
137
- }}
138
- >
139
- <Container flexDirection="column" gap={0}>
140
- {fieldConfigs.map((field, idx) => {
141
- const isSelected = idx === selectedIndex;
142
- const displayValue = getDisplayValue(
143
- field.key,
144
- values[field.key],
145
- field.type
146
- );
147
-
148
- return (
149
- <Field
150
- key={field.key}
151
- label={field.label}
152
- value={displayValue}
153
- selected={isSelected}
154
- />
155
- );
156
- })}
157
-
158
- {additionalButtons.map((btn, idx) => {
159
- const buttonSelectedIndex = fieldConfigs.length + idx;
160
- return (
161
- <MenuButton
162
- key={btn.label}
163
- label={btn.label}
164
- selected={selectedIndex === buttonSelectedIndex}
165
- />
166
- );
167
- })}
168
-
169
- {actionButton}
170
- </Container>
171
- </ScrollView>
172
- </Panel>
173
- );
174
- }
File without changes
@@ -1,32 +0,0 @@
1
- import { Container } from "../semantic/Container.tsx";
2
- import { Label } from "../semantic/Label.tsx";
3
- import { Spacer } from "../semantic/Spacer.tsx";
4
-
5
- interface HeaderProps {
6
- /** Application name */
7
- name: string;
8
- /** Application version */
9
- version: string;
10
- /** Optional breadcrumb path (e.g., ["run", "config"]) */
11
- breadcrumb?: string[];
12
- }
13
-
14
- /**
15
- * Application header with name, version, and optional breadcrumb.
16
- */
17
- export function Header({ name, version, breadcrumb }: HeaderProps) {
18
- const breadcrumbStr = breadcrumb?.length ? ` › ${breadcrumb.join(" › ")}` : "";
19
-
20
- return (
21
- <Container flexDirection="column" noShrink>
22
- <Container flexDirection="row" justifyContent="space-between">
23
- <Label color="mutedText" bold>
24
- {name}
25
- {breadcrumbStr}
26
- </Label>
27
- <Label color="mutedText">v{version}</Label>
28
- </Container>
29
- <Spacer size={1} />
30
- </Container>
31
- );
32
- }
@@ -1,38 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { Panel } from "../semantic/Panel.tsx";
3
- import { Container } from "../semantic/Container.tsx";
4
- import { Overlay } from "../semantic/Overlay.tsx";
5
-
6
- type Dim = number | `${number}%` | "auto";
7
-
8
- interface ModalBaseProps {
9
- title?: string;
10
- width?: Dim;
11
- height?: Dim;
12
- top?: Dim;
13
- left?: Dim;
14
- right?: Dim;
15
- bottom?: Dim;
16
- children: ReactNode;
17
- }
18
-
19
- export function ModalBase({
20
- title,
21
- width = "80%",
22
- height = "auto",
23
- top = 4,
24
- left = 4,
25
- right,
26
- bottom,
27
- children,
28
- }: ModalBaseProps) {
29
- return (
30
- <Overlay zIndex={20} top={top} left={left} right={right} bottom={bottom} width={width} height={height}>
31
- <Panel title={title} border={true} flexDirection="column" flex={1} padding={1} surface="overlay">
32
- <Container flexDirection="column" gap={1} flex={1}>
33
- {children}
34
- </Container>
35
- </Panel>
36
- </Overlay>
37
- );
38
- }