@pablozaiden/terminatui 0.1.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/.devcontainer/devcontainer.json +19 -0
- package/.devcontainer/install-prerequisites.sh +49 -0
- package/.github/workflows/copilot-setup-steps.yml +32 -0
- package/.github/workflows/pull-request.yml +27 -0
- package/.github/workflows/release-npm-package.yml +78 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/examples/tui-app/commands/greet.ts +75 -0
- package/examples/tui-app/commands/index.ts +3 -0
- package/examples/tui-app/commands/math.ts +114 -0
- package/examples/tui-app/commands/status.ts +75 -0
- package/examples/tui-app/index.ts +34 -0
- package/guides/01-hello-world.md +96 -0
- package/guides/02-adding-options.md +103 -0
- package/guides/03-multiple-commands.md +163 -0
- package/guides/04-subcommands.md +206 -0
- package/guides/05-interactive-tui.md +194 -0
- package/guides/06-config-validation.md +264 -0
- package/guides/07-async-cancellation.md +388 -0
- package/guides/08-complete-application.md +673 -0
- package/guides/README.md +74 -0
- package/package.json +32 -0
- package/src/__tests__/application.test.ts +425 -0
- package/src/__tests__/buildCliCommand.test.ts +125 -0
- package/src/__tests__/builtins.test.ts +133 -0
- package/src/__tests__/colors.test.ts +127 -0
- package/src/__tests__/command.test.ts +157 -0
- package/src/__tests__/commandClass.test.ts +130 -0
- package/src/__tests__/context.test.ts +97 -0
- package/src/__tests__/help.test.ts +412 -0
- package/src/__tests__/parser.test.ts +268 -0
- package/src/__tests__/registry.test.ts +195 -0
- package/src/__tests__/registryNew.test.ts +160 -0
- package/src/__tests__/schemaToFields.test.ts +176 -0
- package/src/__tests__/table.test.ts +146 -0
- package/src/__tests__/tui.test.ts +26 -0
- package/src/builtins/help.ts +85 -0
- package/src/builtins/index.ts +4 -0
- package/src/builtins/settings.ts +106 -0
- package/src/builtins/version.ts +72 -0
- package/src/cli/help.ts +174 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/output/colors.ts +74 -0
- package/src/cli/output/index.ts +2 -0
- package/src/cli/output/table.ts +141 -0
- package/src/cli/parser.ts +241 -0
- package/src/commands/help.ts +50 -0
- package/src/commands/index.ts +1 -0
- package/src/components/index.ts +147 -0
- package/src/core/application.ts +461 -0
- package/src/core/command.ts +269 -0
- package/src/core/context.ts +112 -0
- package/src/core/help.ts +214 -0
- package/src/core/index.ts +15 -0
- package/src/core/logger.ts +164 -0
- package/src/core/registry.ts +140 -0
- package/src/hooks/index.ts +131 -0
- package/src/index.ts +137 -0
- package/src/registry/commandRegistry.ts +77 -0
- package/src/registry/index.ts +1 -0
- package/src/tui/TuiApp.tsx +582 -0
- package/src/tui/TuiApplication.tsx +230 -0
- package/src/tui/app.ts +29 -0
- package/src/tui/components/ActionButton.tsx +36 -0
- package/src/tui/components/CliModal.tsx +81 -0
- package/src/tui/components/CommandSelector.tsx +159 -0
- package/src/tui/components/ConfigForm.tsx +148 -0
- package/src/tui/components/EditorModal.tsx +177 -0
- package/src/tui/components/FieldRow.tsx +30 -0
- package/src/tui/components/Header.tsx +31 -0
- package/src/tui/components/JsonHighlight.tsx +128 -0
- package/src/tui/components/LogsPanel.tsx +86 -0
- package/src/tui/components/ResultsPanel.tsx +93 -0
- package/src/tui/components/StatusBar.tsx +59 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/components/types.ts +30 -0
- package/src/tui/context/KeyboardContext.tsx +118 -0
- package/src/tui/context/index.ts +7 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useClipboard.ts +66 -0
- package/src/tui/hooks/useCommandExecutor.ts +131 -0
- package/src/tui/hooks/useConfigState.ts +171 -0
- package/src/tui/hooks/useKeyboardHandler.ts +91 -0
- package/src/tui/hooks/useLogStream.ts +96 -0
- package/src/tui/hooks/useSpinner.ts +46 -0
- package/src/tui/index.ts +65 -0
- package/src/tui/theme.ts +21 -0
- package/src/tui/utils/buildCliCommand.ts +90 -0
- package/src/tui/utils/index.ts +13 -0
- package/src/tui/utils/parameterPersistence.ts +96 -0
- package/src/tui/utils/schemaToFields.ts +144 -0
- package/src/types/command.ts +103 -0
- package/src/types/execution.ts +11 -0
- package/src/types/index.ts +1 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { SelectOption } from "@opentui/core";
|
|
3
|
+
import { Theme } from "../theme.ts";
|
|
4
|
+
import { useKeyboardHandler, KeyboardPriority } from "../hooks/useKeyboardHandler.ts";
|
|
5
|
+
import type { FieldConfig } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
interface EditorModalProps {
|
|
8
|
+
/** The key of the field being edited */
|
|
9
|
+
fieldKey: string | null;
|
|
10
|
+
/** The current value of the field */
|
|
11
|
+
currentValue: unknown;
|
|
12
|
+
/** Whether the modal is visible */
|
|
13
|
+
visible: boolean;
|
|
14
|
+
/** Called when the user submits a new value */
|
|
15
|
+
onSubmit: (value: unknown) => void;
|
|
16
|
+
/** Called when the user cancels editing */
|
|
17
|
+
onCancel: () => void;
|
|
18
|
+
/** Field configurations */
|
|
19
|
+
fieldConfigs: FieldConfig[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Modal for editing field values.
|
|
24
|
+
* Supports text, number, enum, and boolean types.
|
|
25
|
+
*/
|
|
26
|
+
export function EditorModal({
|
|
27
|
+
fieldKey,
|
|
28
|
+
currentValue,
|
|
29
|
+
visible,
|
|
30
|
+
onSubmit,
|
|
31
|
+
onCancel,
|
|
32
|
+
fieldConfigs,
|
|
33
|
+
}: EditorModalProps) {
|
|
34
|
+
const [inputValue, setInputValue] = useState("");
|
|
35
|
+
const [selectIndex, setSelectIndex] = useState(0);
|
|
36
|
+
|
|
37
|
+
// Reset state when field changes
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (fieldKey && visible) {
|
|
40
|
+
setInputValue(String(currentValue ?? ""));
|
|
41
|
+
|
|
42
|
+
// For enums, find current index
|
|
43
|
+
const fieldConfig = fieldConfigs.find((f) => f.key === fieldKey);
|
|
44
|
+
if (fieldConfig?.options) {
|
|
45
|
+
const idx = fieldConfig.options.findIndex((o) => o.value === currentValue);
|
|
46
|
+
setSelectIndex(idx >= 0 ? idx : 0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}, [fieldKey, currentValue, visible, fieldConfigs]);
|
|
50
|
+
|
|
51
|
+
// Modal keyboard handler - blocks all keys from bubbling out of the modal
|
|
52
|
+
useKeyboardHandler(
|
|
53
|
+
(event) => {
|
|
54
|
+
if (event.key.name === "escape") {
|
|
55
|
+
onCancel();
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
KeyboardPriority.Modal,
|
|
59
|
+
{ enabled: visible, modal: true }
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!visible || !fieldKey) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fieldConfig = fieldConfigs.find((f) => f.key === fieldKey);
|
|
67
|
+
if (!fieldConfig) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isEnum = fieldConfig.type === "enum" && fieldConfig.options;
|
|
72
|
+
const isBoolean = fieldConfig.type === "boolean";
|
|
73
|
+
const isNumber = fieldConfig.type === "number";
|
|
74
|
+
|
|
75
|
+
const handleInputSubmit = (value: string) => {
|
|
76
|
+
if (isNumber) {
|
|
77
|
+
onSubmit(parseInt(value.replace(/[^0-9-]/g, ""), 10) || 0);
|
|
78
|
+
} else {
|
|
79
|
+
onSubmit(value);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSelectIndexChange = (index: number, _option: SelectOption | null) => {
|
|
84
|
+
setSelectIndex(index);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleSelectSubmit = (_index: number, option: SelectOption | null) => {
|
|
88
|
+
if (option) {
|
|
89
|
+
onSubmit(option.value);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleBooleanSubmit = (_index: number, option: SelectOption | null) => {
|
|
94
|
+
if (option) {
|
|
95
|
+
onSubmit(option.value === true);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Boolean uses select with True/False options
|
|
100
|
+
const booleanOptions: SelectOption[] = [
|
|
101
|
+
{ name: "False", description: "", value: false },
|
|
102
|
+
{ name: "True", description: "", value: true },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<box
|
|
107
|
+
position="absolute"
|
|
108
|
+
top={4}
|
|
109
|
+
left={6}
|
|
110
|
+
width="60%"
|
|
111
|
+
height={12}
|
|
112
|
+
backgroundColor={Theme.overlay}
|
|
113
|
+
border={true}
|
|
114
|
+
borderStyle="rounded"
|
|
115
|
+
borderColor={Theme.overlayTitle}
|
|
116
|
+
padding={1}
|
|
117
|
+
flexDirection="column"
|
|
118
|
+
gap={1}
|
|
119
|
+
zIndex={20}
|
|
120
|
+
>
|
|
121
|
+
<text fg={Theme.overlayTitle}>
|
|
122
|
+
<strong>Edit: {fieldConfig.label}</strong>
|
|
123
|
+
</text>
|
|
124
|
+
|
|
125
|
+
{isEnum && fieldConfig.options && (
|
|
126
|
+
<select
|
|
127
|
+
options={fieldConfig.options.map((o) => ({
|
|
128
|
+
name: o.name,
|
|
129
|
+
value: o.value,
|
|
130
|
+
description: "",
|
|
131
|
+
}))}
|
|
132
|
+
selectedIndex={selectIndex}
|
|
133
|
+
focused={true}
|
|
134
|
+
onChange={handleSelectIndexChange}
|
|
135
|
+
onSelect={handleSelectSubmit}
|
|
136
|
+
showScrollIndicator={true}
|
|
137
|
+
showDescription={false}
|
|
138
|
+
height={6}
|
|
139
|
+
width="100%"
|
|
140
|
+
wrapSelection={true}
|
|
141
|
+
selectedBackgroundColor="#61afef"
|
|
142
|
+
selectedTextColor="#1e2127"
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{isBoolean && (
|
|
147
|
+
<select
|
|
148
|
+
options={booleanOptions}
|
|
149
|
+
selectedIndex={currentValue ? 1 : 0}
|
|
150
|
+
focused={true}
|
|
151
|
+
onSelect={handleBooleanSubmit}
|
|
152
|
+
showScrollIndicator={false}
|
|
153
|
+
showDescription={false}
|
|
154
|
+
height={2}
|
|
155
|
+
width="100%"
|
|
156
|
+
wrapSelection={true}
|
|
157
|
+
selectedBackgroundColor="#61afef"
|
|
158
|
+
selectedTextColor="#1e2127"
|
|
159
|
+
/>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{!isEnum && !isBoolean && (
|
|
163
|
+
<input
|
|
164
|
+
value={inputValue}
|
|
165
|
+
placeholder={fieldConfig.placeholder ?? `Enter ${fieldConfig.label.toLowerCase()}...`}
|
|
166
|
+
focused={true}
|
|
167
|
+
onInput={(value) => setInputValue(value)}
|
|
168
|
+
onSubmit={handleInputSubmit}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<text fg={Theme.statusText}>
|
|
173
|
+
Enter to save, Esc to cancel
|
|
174
|
+
</text>
|
|
175
|
+
</box>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
|
|
3
|
+
interface FieldRowProps {
|
|
4
|
+
/** Field label */
|
|
5
|
+
label: string;
|
|
6
|
+
/** Field value to display */
|
|
7
|
+
value: string;
|
|
8
|
+
/** Whether this row is selected */
|
|
9
|
+
isSelected: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A single row in a config form displaying a field label and value.
|
|
14
|
+
*/
|
|
15
|
+
export function FieldRow({ label, value, isSelected }: FieldRowProps) {
|
|
16
|
+
const prefix = isSelected ? "► " : " ";
|
|
17
|
+
const labelColor = isSelected ? Theme.borderFocused : Theme.label;
|
|
18
|
+
const valueColor = isSelected ? Theme.value : Theme.statusText;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<box flexDirection="row" gap={1}>
|
|
22
|
+
<text fg={labelColor}>
|
|
23
|
+
{prefix}{label}:
|
|
24
|
+
</text>
|
|
25
|
+
<text fg={valueColor}>
|
|
26
|
+
{value}
|
|
27
|
+
</text>
|
|
28
|
+
</box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
|
|
3
|
+
interface HeaderProps {
|
|
4
|
+
/** Application name */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Application version */
|
|
7
|
+
version: string;
|
|
8
|
+
/** Optional breadcrumb path (e.g., ["run", "config"]) */
|
|
9
|
+
breadcrumb?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Application header with name, version, and optional breadcrumb.
|
|
14
|
+
*/
|
|
15
|
+
export function Header({ name, version, breadcrumb }: HeaderProps) {
|
|
16
|
+
const breadcrumbStr = breadcrumb?.length
|
|
17
|
+
? ` › ${breadcrumb.join(" › ")}`
|
|
18
|
+
: "";
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<box flexDirection="row" justifyContent="space-between" marginBottom={1}>
|
|
22
|
+
<text fg={Theme.header}>
|
|
23
|
+
<strong>{name}</strong>
|
|
24
|
+
{breadcrumbStr}
|
|
25
|
+
</text>
|
|
26
|
+
<text fg={Theme.label}>
|
|
27
|
+
v{version}
|
|
28
|
+
</text>
|
|
29
|
+
</box>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON syntax highlighting types and colors
|
|
5
|
+
*/
|
|
6
|
+
type JsonTokenType = "key" | "string" | "number" | "boolean" | "null" | "punctuation";
|
|
7
|
+
type JsonToken = { type: JsonTokenType; value: string };
|
|
8
|
+
type JsonLineTokens = JsonToken[];
|
|
9
|
+
|
|
10
|
+
const TOKEN_COLORS: Record<JsonTokenType, string> = {
|
|
11
|
+
key: "#61afef", // blue
|
|
12
|
+
string: "#98c379", // green
|
|
13
|
+
number: "#d19a66", // orange
|
|
14
|
+
boolean: "#c678dd", // purple
|
|
15
|
+
null: "#c678dd", // purple
|
|
16
|
+
punctuation: Theme.label,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function tokenizeJson(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 = tokenizeJson(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 = tokenizeJson(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
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface JsonHighlightProps {
|
|
101
|
+
/** The value to render as syntax-highlighted JSON */
|
|
102
|
+
value: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Render JSON with syntax highlighting.
|
|
107
|
+
*
|
|
108
|
+
* Tokenizes the JSON value and renders each token with appropriate colors:
|
|
109
|
+
* - Keys: blue
|
|
110
|
+
* - Strings: green
|
|
111
|
+
* - Numbers: orange
|
|
112
|
+
* - Booleans/null: purple
|
|
113
|
+
* - Punctuation: theme label color
|
|
114
|
+
*/
|
|
115
|
+
export function JsonHighlight({ value }: JsonHighlightProps) {
|
|
116
|
+
const lines = tokenizeJson(value);
|
|
117
|
+
return (
|
|
118
|
+
<box flexDirection="column">
|
|
119
|
+
{lines.map((tokens, lineIdx) => (
|
|
120
|
+
<text key={`json-${lineIdx}`}>
|
|
121
|
+
{tokens.map((token, tokenIdx) => (
|
|
122
|
+
<span key={tokenIdx} fg={TOKEN_COLORS[token.type]}>{token.value}</span>
|
|
123
|
+
))}
|
|
124
|
+
</text>
|
|
125
|
+
))}
|
|
126
|
+
</box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
import { LogLevel, type LogEntry } from "../hooks/useLogStream.ts";
|
|
3
|
+
|
|
4
|
+
// Colors for different log levels
|
|
5
|
+
const LogColors: Record<LogLevel, string> = {
|
|
6
|
+
[LogLevel.Silly]: "#8c8c8c",
|
|
7
|
+
[LogLevel.Trace]: "#6dd6ff",
|
|
8
|
+
[LogLevel.Debug]: "#7bdcb5",
|
|
9
|
+
[LogLevel.Info]: "#d6dde6",
|
|
10
|
+
[LogLevel.Warn]: "#f5c542",
|
|
11
|
+
[LogLevel.Error]: "#f78888",
|
|
12
|
+
[LogLevel.Fatal]: "#ff5c8d",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
interface LogsPanelProps {
|
|
16
|
+
/** Log entries to display */
|
|
17
|
+
logs: LogEntry[];
|
|
18
|
+
/** Whether the panel is visible */
|
|
19
|
+
visible: boolean;
|
|
20
|
+
/** Whether the panel is focused */
|
|
21
|
+
focused: boolean;
|
|
22
|
+
/** Whether to expand to fill available space */
|
|
23
|
+
expanded?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Panel displaying log entries with color-coded levels.
|
|
28
|
+
*/
|
|
29
|
+
export function LogsPanel({
|
|
30
|
+
logs,
|
|
31
|
+
visible,
|
|
32
|
+
focused,
|
|
33
|
+
expanded = false,
|
|
34
|
+
}: LogsPanelProps) {
|
|
35
|
+
if (!visible) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const borderColor = focused ? Theme.borderFocused : Theme.border;
|
|
40
|
+
const title = `Logs - ${logs.length}`;
|
|
41
|
+
|
|
42
|
+
// When expanded, grow to fill. Otherwise fixed height.
|
|
43
|
+
const boxProps = expanded
|
|
44
|
+
? { flexGrow: 1 }
|
|
45
|
+
: { height: 10, flexShrink: 0 };
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<box
|
|
49
|
+
flexDirection="column"
|
|
50
|
+
border={true}
|
|
51
|
+
borderStyle="rounded"
|
|
52
|
+
borderColor={borderColor}
|
|
53
|
+
title={title}
|
|
54
|
+
padding={1}
|
|
55
|
+
{...boxProps}
|
|
56
|
+
>
|
|
57
|
+
<scrollbox
|
|
58
|
+
scrollY={true}
|
|
59
|
+
flexGrow={1}
|
|
60
|
+
stickyScroll={true}
|
|
61
|
+
stickyStart="bottom"
|
|
62
|
+
focused={focused}
|
|
63
|
+
>
|
|
64
|
+
<box flexDirection="column" gap={0}>
|
|
65
|
+
{logs.map((log, idx) => {
|
|
66
|
+
const color = LogColors[log.level] ?? Theme.statusText;
|
|
67
|
+
// Strip ANSI codes but preserve line breaks
|
|
68
|
+
const sanitized = typeof Bun !== "undefined"
|
|
69
|
+
? Bun.stripANSI(log.message).trim()
|
|
70
|
+
: log.message.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "").trim();
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<text key={`${log.timestamp.getTime()}-${idx}`} fg={color}>
|
|
74
|
+
{sanitized}
|
|
75
|
+
</text>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
|
|
79
|
+
{logs.length === 0 && (
|
|
80
|
+
<text fg={Theme.label}>No logs yet...</text>
|
|
81
|
+
)}
|
|
82
|
+
</box>
|
|
83
|
+
</scrollbox>
|
|
84
|
+
</box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Theme } from "../theme.ts";
|
|
3
|
+
import type { CommandResult } from "../../core/command.ts";
|
|
4
|
+
|
|
5
|
+
interface ResultsPanelProps {
|
|
6
|
+
/** The result to display */
|
|
7
|
+
result: CommandResult | null;
|
|
8
|
+
/** Error to display (if any) */
|
|
9
|
+
error: Error | null;
|
|
10
|
+
/** Whether the panel is focused */
|
|
11
|
+
focused: boolean;
|
|
12
|
+
/** Custom result renderer */
|
|
13
|
+
renderResult?: (result: CommandResult) => ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Panel displaying command execution results.
|
|
18
|
+
*/
|
|
19
|
+
export function ResultsPanel({
|
|
20
|
+
result,
|
|
21
|
+
error,
|
|
22
|
+
focused,
|
|
23
|
+
renderResult,
|
|
24
|
+
}: ResultsPanelProps) {
|
|
25
|
+
const borderColor = focused ? Theme.borderFocused : Theme.border;
|
|
26
|
+
|
|
27
|
+
// Determine content to display
|
|
28
|
+
let content: ReactNode;
|
|
29
|
+
|
|
30
|
+
if (error) {
|
|
31
|
+
content = (
|
|
32
|
+
<box flexDirection="column" gap={1}>
|
|
33
|
+
<text fg={Theme.error}>
|
|
34
|
+
<strong>Error</strong>
|
|
35
|
+
</text>
|
|
36
|
+
<text fg={Theme.error}>
|
|
37
|
+
{error.message}
|
|
38
|
+
</text>
|
|
39
|
+
</box>
|
|
40
|
+
);
|
|
41
|
+
} else if (result) {
|
|
42
|
+
if (renderResult) {
|
|
43
|
+
const customContent = renderResult(result);
|
|
44
|
+
|
|
45
|
+
if (typeof customContent === "string" || typeof customContent === "number" || typeof customContent === "boolean") {
|
|
46
|
+
// Wrap primitive results so the renderer gets a text node
|
|
47
|
+
content = (
|
|
48
|
+
<text fg={Theme.value}>
|
|
49
|
+
{String(customContent)}
|
|
50
|
+
</text>
|
|
51
|
+
);
|
|
52
|
+
} else {
|
|
53
|
+
content = customContent as ReactNode;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// Default JSON display
|
|
57
|
+
content = (
|
|
58
|
+
<box flexDirection="column" gap={1}>
|
|
59
|
+
{result.message && (
|
|
60
|
+
<text fg={result.success ? Theme.success : Theme.error}>
|
|
61
|
+
{result.message}
|
|
62
|
+
</text>
|
|
63
|
+
)}
|
|
64
|
+
{result.data !== undefined && result.data !== null && (
|
|
65
|
+
<text fg={Theme.value}>
|
|
66
|
+
{JSON.stringify(result.data, null, 2)}
|
|
67
|
+
</text>
|
|
68
|
+
)}
|
|
69
|
+
</box>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
content = (
|
|
74
|
+
<text fg={Theme.label}>No results yet...</text>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<box
|
|
80
|
+
flexDirection="column"
|
|
81
|
+
border={true}
|
|
82
|
+
borderStyle="rounded"
|
|
83
|
+
borderColor={borderColor}
|
|
84
|
+
title="Results"
|
|
85
|
+
padding={1}
|
|
86
|
+
flexGrow={1}
|
|
87
|
+
>
|
|
88
|
+
<scrollbox scrollY={true} flexGrow={1} focused={focused}>
|
|
89
|
+
{content}
|
|
90
|
+
</scrollbox>
|
|
91
|
+
</box>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Theme } from "../theme.ts";
|
|
2
|
+
import { useSpinner } from "../hooks/useSpinner.ts";
|
|
3
|
+
|
|
4
|
+
interface StatusBarProps {
|
|
5
|
+
/** Status message to display */
|
|
6
|
+
status: string;
|
|
7
|
+
/** Whether the app is currently running a command */
|
|
8
|
+
isRunning?: boolean;
|
|
9
|
+
/** Whether to show keyboard shortcuts */
|
|
10
|
+
showShortcuts?: boolean;
|
|
11
|
+
/** Custom shortcuts string (defaults to standard shortcuts) */
|
|
12
|
+
shortcuts?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Status bar showing current status, spinner, and keyboard shortcuts.
|
|
17
|
+
*/
|
|
18
|
+
export function StatusBar({
|
|
19
|
+
status,
|
|
20
|
+
isRunning = false,
|
|
21
|
+
showShortcuts = true,
|
|
22
|
+
shortcuts = "L Logs • C CLI • Tab Switch • Ctrl+Y Copy • Esc Back"
|
|
23
|
+
}: StatusBarProps) {
|
|
24
|
+
const { frame } = useSpinner(isRunning);
|
|
25
|
+
const spinner = isRunning ? `${frame} ` : "";
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<box
|
|
29
|
+
flexDirection="column"
|
|
30
|
+
gap={0}
|
|
31
|
+
border={true}
|
|
32
|
+
borderStyle="rounded"
|
|
33
|
+
borderColor={isRunning ? "#4ade80" : Theme.border}
|
|
34
|
+
flexShrink={0}
|
|
35
|
+
>
|
|
36
|
+
{/* Main status with spinner */}
|
|
37
|
+
<box
|
|
38
|
+
flexDirection="row"
|
|
39
|
+
justifyContent="space-between"
|
|
40
|
+
backgroundColor={isRunning ? "#1a1a2e" : undefined}
|
|
41
|
+
paddingLeft={1}
|
|
42
|
+
paddingRight={1}
|
|
43
|
+
>
|
|
44
|
+
<text fg={isRunning ? "#4ade80" : Theme.statusText}>
|
|
45
|
+
{isRunning ? <strong>{spinner}{status}</strong> : <>{spinner}{status}</>}
|
|
46
|
+
</text>
|
|
47
|
+
</box>
|
|
48
|
+
|
|
49
|
+
{/* Keyboard shortcuts */}
|
|
50
|
+
{showShortcuts && (
|
|
51
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
52
|
+
<text fg={Theme.label}>
|
|
53
|
+
{shortcuts}
|
|
54
|
+
</text>
|
|
55
|
+
</box>
|
|
56
|
+
)}
|
|
57
|
+
</box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { FieldRow } from "./FieldRow.tsx";
|
|
2
|
+
export { ActionButton } from "./ActionButton.tsx";
|
|
3
|
+
export { Header } from "./Header.tsx";
|
|
4
|
+
export { StatusBar } from "./StatusBar.tsx";
|
|
5
|
+
export { LogsPanel } from "./LogsPanel.tsx";
|
|
6
|
+
export { ResultsPanel } from "./ResultsPanel.tsx";
|
|
7
|
+
export { ConfigForm } from "./ConfigForm.tsx";
|
|
8
|
+
export { EditorModal } from "./EditorModal.tsx";
|
|
9
|
+
export { CliModal } from "./CliModal.tsx";
|
|
10
|
+
export { CommandSelector } from "./CommandSelector.tsx";
|
|
11
|
+
export { JsonHighlight, type JsonHighlightProps } from "./JsonHighlight.tsx";
|
|
12
|
+
|
|
13
|
+
export type { FieldType, FieldOption, FieldConfig } from "./types.ts";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field type for TUI forms.
|
|
3
|
+
*/
|
|
4
|
+
export type FieldType = "text" | "number" | "enum" | "boolean";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Option for enum/select fields.
|
|
8
|
+
*/
|
|
9
|
+
export interface FieldOption {
|
|
10
|
+
name: string;
|
|
11
|
+
value: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Field configuration for TUI forms.
|
|
16
|
+
*/
|
|
17
|
+
export interface FieldConfig {
|
|
18
|
+
/** Field key (must match a key in values) */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Display label */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Field type */
|
|
23
|
+
type: FieldType;
|
|
24
|
+
/** Options for enum type */
|
|
25
|
+
options?: FieldOption[];
|
|
26
|
+
/** Placeholder text for input fields */
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
/** Group name for organizing fields */
|
|
29
|
+
group?: string;
|
|
30
|
+
}
|