@pablozaiden/terminatui 0.3.0 → 0.4.1
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 +1 -1
- package/src/__tests__/adapterNoSharedUi.test.ts +34 -0
- package/src/__tests__/schemaToFields.test.ts +0 -4
- package/src/__tests__/tuiRootNoCoupling.test.ts +25 -0
- package/src/index.ts +2 -2
- 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 -45
- 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 -43
- 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 -46
- 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/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 -165
- package/src/tui/screens/ErrorScreen.tsx +0 -58
- package/src/tui/screens/ResultsScreen.tsx +0 -68
- 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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useRef, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Field } from "../components/Field.tsx";
|
|
3
|
+
import { MenuButton } from "../components/MenuButton.tsx";
|
|
4
|
+
import { Panel } from "../components/Panel.tsx";
|
|
5
|
+
import { ScrollView } from "../components/ScrollView.tsx";
|
|
6
|
+
import type { FieldConfig } from "../../../semantic/types.ts";
|
|
7
|
+
import type { ScrollViewRef } from "../../../semantic/layoutTypes.ts";
|
|
8
|
+
|
|
9
|
+
interface ConfigFormProps {
|
|
10
|
+
title: string;
|
|
11
|
+
fieldConfigs: FieldConfig[];
|
|
12
|
+
values: Record<string, unknown>;
|
|
13
|
+
selectedIndex: number;
|
|
14
|
+
focused: boolean;
|
|
15
|
+
getDisplayValue?: (key: string, value: unknown, type: string) => string;
|
|
16
|
+
actionButton: ReactNode;
|
|
17
|
+
additionalButtons?: { label: string; onPress: () => void }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultGetDisplayValue(_key: string, value: unknown, type: string): string {
|
|
21
|
+
if (type === "boolean") {
|
|
22
|
+
return value ? "True" : "False";
|
|
23
|
+
}
|
|
24
|
+
const strValue = String(value ?? "");
|
|
25
|
+
if (strValue === "") {
|
|
26
|
+
return "(empty)";
|
|
27
|
+
}
|
|
28
|
+
return strValue.length > 60 ? strValue.substring(0, 57) + "..." : strValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ConfigForm({
|
|
32
|
+
title,
|
|
33
|
+
fieldConfigs,
|
|
34
|
+
values,
|
|
35
|
+
selectedIndex,
|
|
36
|
+
focused,
|
|
37
|
+
getDisplayValue = defaultGetDisplayValue,
|
|
38
|
+
actionButton,
|
|
39
|
+
additionalButtons = [],
|
|
40
|
+
}: ConfigFormProps) {
|
|
41
|
+
const scrollViewRef = useRef<ScrollViewRef | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
scrollViewRef.current?.scrollToIndex(selectedIndex);
|
|
45
|
+
}, [selectedIndex]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Panel title={title} focused={focused} flex={1} padding={1} flexDirection="column">
|
|
49
|
+
<ScrollView
|
|
50
|
+
axis="vertical"
|
|
51
|
+
flex={1}
|
|
52
|
+
scrollRef={(ref) => {
|
|
53
|
+
scrollViewRef.current = ref;
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<box flexDirection="column" gap={0}>
|
|
57
|
+
{fieldConfigs.map((field, idx) => {
|
|
58
|
+
const isSelected = idx === selectedIndex;
|
|
59
|
+
const displayValue = getDisplayValue(field.key, values[field.key], field.type);
|
|
60
|
+
|
|
61
|
+
return <Field key={field.key} label={field.label} value={displayValue} selected={isSelected} />;
|
|
62
|
+
})}
|
|
63
|
+
|
|
64
|
+
{additionalButtons.map((btn, idx) => {
|
|
65
|
+
const buttonSelectedIndex = fieldConfigs.length + idx;
|
|
66
|
+
return <MenuButton key={btn.label} label={btn.label} selected={selectedIndex === buttonSelectedIndex} />;
|
|
67
|
+
})}
|
|
68
|
+
|
|
69
|
+
{actionButton}
|
|
70
|
+
</box>
|
|
71
|
+
</ScrollView>
|
|
72
|
+
</Panel>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Label } from "../components/Label.tsx";
|
|
2
|
+
|
|
3
|
+
interface HeaderProps {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
breadcrumb?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Header({ name, version, breadcrumb }: HeaderProps) {
|
|
10
|
+
const breadcrumbStr = breadcrumb?.length ? ` 3 ${breadcrumb.join(" 3 ")}` : "";
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<box flexDirection="column" flexShrink={0}>
|
|
14
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
15
|
+
<Label color="mutedText" bold>
|
|
16
|
+
{name}
|
|
17
|
+
{breadcrumbStr}
|
|
18
|
+
</Label>
|
|
19
|
+
<Label color="mutedText">v{version}</Label>
|
|
20
|
+
</box>
|
|
21
|
+
<box height={1} />
|
|
22
|
+
</box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { tokenizeJsonValue } from "../../../utils/jsonTokenizer.ts";
|
|
2
|
+
import { CodeHighlight } from "../components/CodeHighlight.tsx";
|
|
3
|
+
|
|
4
|
+
export interface JsonHighlightProps {
|
|
5
|
+
value: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function JsonHighlight({ value }: JsonHighlightProps) {
|
|
9
|
+
const lines = tokenizeJsonValue(value);
|
|
10
|
+
return (
|
|
11
|
+
<box flexDirection="column" gap={0}>
|
|
12
|
+
{lines.map((tokens, lineIdx) => (
|
|
13
|
+
<CodeHighlight
|
|
14
|
+
key={`json-${lineIdx}`}
|
|
15
|
+
tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
|
|
16
|
+
/>
|
|
17
|
+
))}
|
|
18
|
+
</box>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import { Label } from "../components/Label.tsx";
|
|
3
|
+
import { Overlay } from "../components/Overlay.tsx";
|
|
4
|
+
import { SemanticColors } from "../../../theme.ts";
|
|
5
|
+
import type { LogsScreenProps } from "../../../semantic/LogsScreen.tsx";
|
|
6
|
+
|
|
7
|
+
export function LogsPanel({ items }: LogsScreenProps) {
|
|
8
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
9
|
+
|
|
10
|
+
// Panel takes most of terminal size, leaving some margin to show it's a modal
|
|
11
|
+
// ~90% width/height with minimum sizes
|
|
12
|
+
const panelHeight = Math.max(10, Math.floor(terminalHeight * 0.75));
|
|
13
|
+
const panelWidth = Math.max(40, Math.floor(terminalWidth * 0.85));
|
|
14
|
+
|
|
15
|
+
// Scrollbox height = panel height - border (2) - padding (2) - title (1) - footer (1)
|
|
16
|
+
const scrollboxHeight = panelHeight - 6;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Overlay>
|
|
20
|
+
<box
|
|
21
|
+
flexDirection="column"
|
|
22
|
+
padding={1}
|
|
23
|
+
border={true}
|
|
24
|
+
borderStyle="rounded"
|
|
25
|
+
borderColor={SemanticColors.warning}
|
|
26
|
+
backgroundColor={SemanticColors.overlay}
|
|
27
|
+
width={panelWidth}
|
|
28
|
+
height={panelHeight}
|
|
29
|
+
>
|
|
30
|
+
<Label bold>Logs</Label>
|
|
31
|
+
<scrollbox scrollY height={scrollboxHeight}>
|
|
32
|
+
<box flexDirection="column">
|
|
33
|
+
{items.map((item) => (
|
|
34
|
+
<Label color="value" key={item.timestamp}>
|
|
35
|
+
{`[${item.level}] ${Bun.stripANSI(item.message)}`}
|
|
36
|
+
</Label>
|
|
37
|
+
))}
|
|
38
|
+
</box>
|
|
39
|
+
</scrollbox>
|
|
40
|
+
<Label color="mutedText">Enter or Esc to close</Label>
|
|
41
|
+
</box>
|
|
42
|
+
</Overlay>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { CommandResult } from "../../../../core/command.ts";
|
|
3
|
+
|
|
4
|
+
// Platform-native components (OpenTUI)
|
|
5
|
+
import { Panel } from "../components/Panel.tsx";
|
|
6
|
+
import { ScrollView } from "../components/ScrollView.tsx";
|
|
7
|
+
import { Label } from "../components/Label.tsx";
|
|
8
|
+
|
|
9
|
+
// Adapter-local JSON highlighting
|
|
10
|
+
import { JsonHighlight } from "./JsonHighlight.tsx";
|
|
11
|
+
|
|
12
|
+
interface ResultsPanelProps {
|
|
13
|
+
result: CommandResult | null;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
focused: boolean;
|
|
16
|
+
renderResult?: (result: CommandResult) => ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ResultsPanel({ result, error, focused, renderResult }: ResultsPanelProps) {
|
|
20
|
+
let content: ReactNode;
|
|
21
|
+
|
|
22
|
+
if (error) {
|
|
23
|
+
content = (
|
|
24
|
+
<box flexDirection="column" gap={1}>
|
|
25
|
+
<Label color="error" bold>
|
|
26
|
+
Error
|
|
27
|
+
</Label>
|
|
28
|
+
<Label color="error">{error.message}</Label>
|
|
29
|
+
</box>
|
|
30
|
+
);
|
|
31
|
+
} else if (result) {
|
|
32
|
+
if (renderResult) {
|
|
33
|
+
const customContent = renderResult(result);
|
|
34
|
+
if (typeof customContent === "string" || typeof customContent === "number" || typeof customContent === "boolean") {
|
|
35
|
+
content = <Label color="value">{String(customContent)}</Label>;
|
|
36
|
+
} else {
|
|
37
|
+
content = customContent as ReactNode;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
content = (
|
|
41
|
+
<box flexDirection="column" gap={1}>
|
|
42
|
+
{result.message && <Label color={result.success ? "success" : "error"}>{result.message}</Label>}
|
|
43
|
+
{result.data !== undefined && result.data !== null && (
|
|
44
|
+
typeof result.data === "object"
|
|
45
|
+
? <JsonHighlight value={result.data} />
|
|
46
|
+
: <Label color="value">{String(result.data)}</Label>
|
|
47
|
+
)}
|
|
48
|
+
</box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
content = <Label color="mutedText">No results yet...</Label>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Panel title="Results" focused={focused} flex={1} padding={1} flexDirection="column">
|
|
57
|
+
<ScrollView axis="vertical" flex={1} focused={focused}>
|
|
58
|
+
<box flexDirection="column">{content}</box>
|
|
59
|
+
</ScrollView>
|
|
60
|
+
</Panel>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export async function copyToTerminalClipboard(text: string): Promise<boolean> {
|
|
2
|
+
try {
|
|
3
|
+
const cleanText = Bun.stripANSI(text);
|
|
4
|
+
|
|
5
|
+
// On macOS, prefer pbcopy as it's more reliable than OSC52
|
|
6
|
+
// OSC52 doesn't work well in alternate screen mode (OpenTUI) or Apple Terminal
|
|
7
|
+
if (process.platform === "darwin") {
|
|
8
|
+
try {
|
|
9
|
+
const proc = Bun.spawn(["pbcopy"], {
|
|
10
|
+
stdin: "pipe",
|
|
11
|
+
stdout: "ignore",
|
|
12
|
+
stderr: "ignore",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
proc.stdin.write(cleanText);
|
|
16
|
+
proc.stdin.end();
|
|
17
|
+
|
|
18
|
+
const exitCode = await proc.exited;
|
|
19
|
+
if (exitCode === 0) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// pbcopy not available, fall through to OSC52
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// On Linux/other, try xclip or xsel first
|
|
28
|
+
if (process.platform === "linux") {
|
|
29
|
+
try {
|
|
30
|
+
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
|
31
|
+
stdin: "pipe",
|
|
32
|
+
stdout: "ignore",
|
|
33
|
+
stderr: "ignore",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
proc.stdin.write(cleanText);
|
|
37
|
+
proc.stdin.end();
|
|
38
|
+
|
|
39
|
+
const exitCode = await proc.exited;
|
|
40
|
+
if (exitCode === 0) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// xclip not available, fall through to OSC52
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback: Use OSC52 in most terminals; it works across SSH.
|
|
49
|
+
const base64 = Buffer.from(cleanText).toString("base64");
|
|
50
|
+
// Use ESC \ as terminator instead of BEL for better compatibility
|
|
51
|
+
const osc52 = `\x1b]52;c;${base64}\x1b\\`;
|
|
52
|
+
|
|
53
|
+
// Write to /dev/tty if possible, else stdout.
|
|
54
|
+
try {
|
|
55
|
+
const tty = Bun.file("/dev/tty");
|
|
56
|
+
await Bun.write(tty, osc52);
|
|
57
|
+
} catch {
|
|
58
|
+
process.stdout.write(osc52);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
|
|
3
|
-
const SPINNER_FRAMES = ["
|
|
3
|
+
const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
4
4
|
const SPINNER_INTERVAL = 80;
|
|
5
5
|
|
|
6
6
|
interface UseSpinnerResult {
|
|
@@ -8,6 +8,10 @@ interface UseSpinnerResult {
|
|
|
8
8
|
frame: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Shared spinner animation hook for terminal adapters.
|
|
13
|
+
* Returns the current frame character to display.
|
|
14
|
+
*/
|
|
11
15
|
export function useSpinner(active: boolean): UseSpinnerResult {
|
|
12
16
|
const [frameIndex, setFrameIndex] = useState(0);
|
|
13
17
|
|
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
MenuButtonProps,
|
|
10
|
-
MenuItemProps,
|
|
11
|
-
OverlayProps,
|
|
12
|
-
PanelProps,
|
|
13
|
-
ScrollViewProps,
|
|
14
|
-
SelectProps,
|
|
15
|
-
SpacerProps,
|
|
16
|
-
SpinnerProps,
|
|
17
|
-
TextInputProps,
|
|
18
|
-
ValueProps,
|
|
19
|
-
} from "../semantic/types.ts";
|
|
2
|
+
import type { AppShellProps } from "../semantic/AppShell.tsx";
|
|
3
|
+
import type { CommandBrowserScreenProps } from "../semantic/CommandBrowserScreen.tsx";
|
|
4
|
+
import type { ConfigScreenProps } from "../semantic/ConfigScreen.tsx";
|
|
5
|
+
import type { RunningScreenProps } from "../semantic/RunningScreen.tsx";
|
|
6
|
+
import type { LogsScreenProps } from "../semantic/LogsScreen.tsx";
|
|
7
|
+
import type { EditorScreenProps } from "../semantic/EditorScreen.tsx";
|
|
8
|
+
import type { TuiAction } from "../actions.ts";
|
|
20
9
|
|
|
21
10
|
export interface KeyboardEvent {
|
|
22
11
|
name: string;
|
|
23
|
-
sequence?: string;
|
|
24
12
|
ctrl?: boolean;
|
|
25
|
-
shift?: boolean;
|
|
26
|
-
meta?: boolean;
|
|
27
13
|
}
|
|
28
14
|
|
|
29
15
|
export type KeyHandler = (event: KeyboardEvent) => boolean;
|
|
@@ -37,35 +23,28 @@ export interface RendererConfig {
|
|
|
37
23
|
useAlternateScreen?: boolean;
|
|
38
24
|
}
|
|
39
25
|
|
|
40
|
-
export interface RendererComponents {
|
|
41
|
-
Field: (props: FieldProps) => ReactNode;
|
|
42
|
-
Button: (props: ButtonProps) => ReactNode;
|
|
43
|
-
MenuButton: (props: MenuButtonProps) => ReactNode;
|
|
44
|
-
MenuItem: (props: MenuItemProps) => ReactNode;
|
|
45
|
-
|
|
46
|
-
Container: (props: ContainerProps) => ReactNode;
|
|
47
|
-
Panel: (props: PanelProps) => ReactNode;
|
|
48
|
-
ScrollView: (props: ScrollViewProps) => ReactNode;
|
|
49
|
-
|
|
50
|
-
Overlay: (props: OverlayProps) => ReactNode;
|
|
51
|
-
Spacer: (props: SpacerProps) => ReactNode;
|
|
52
|
-
Spinner: (props: SpinnerProps) => ReactNode;
|
|
53
|
-
|
|
54
|
-
Label: (props: LabelProps) => ReactNode;
|
|
55
|
-
Value: (props: ValueProps) => ReactNode;
|
|
56
|
-
Code: (props: CodeProps) => ReactNode;
|
|
57
|
-
CodeHighlight: (props: CodeHighlightProps) => ReactNode;
|
|
58
|
-
|
|
59
|
-
TextInput: (props: TextInputProps) => ReactNode;
|
|
60
|
-
Select: (props: SelectProps) => ReactNode;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
26
|
export interface Renderer {
|
|
64
27
|
initialize: () => Promise<void>;
|
|
65
28
|
render: (node: ReactNode) => void;
|
|
66
29
|
destroy: () => void;
|
|
67
|
-
supportCustomRendering: () => boolean;
|
|
68
30
|
|
|
69
31
|
keyboard: KeyboardAdapter;
|
|
70
|
-
|
|
32
|
+
|
|
33
|
+
renderSemanticAppShell: (props: AppShellProps) => ReactNode;
|
|
34
|
+
renderSemanticCommandBrowserScreen: (props: CommandBrowserScreenProps) => ReactNode;
|
|
35
|
+
renderSemanticConfigScreen: (props: ConfigScreenProps) => ReactNode;
|
|
36
|
+
renderSemanticRunningScreen: (props: RunningScreenProps) => ReactNode;
|
|
37
|
+
renderSemanticLogsScreen: (props: LogsScreenProps) => ReactNode;
|
|
38
|
+
renderSemanticEditorScreen: (props: EditorScreenProps) => ReactNode;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders an invisible component that handles global keyboard bindings.
|
|
42
|
+
* This component can use hooks and will dispatch actions via the provided dispatcher.
|
|
43
|
+
*/
|
|
44
|
+
renderKeyboardHandler?: (props: {
|
|
45
|
+
dispatchAction: (action: TuiAction) => void;
|
|
46
|
+
getScreenKeyHandler: () => ((event: KeyboardEvent) => boolean) | null;
|
|
47
|
+
onCopyToastChange?: (toast: string | null) => void;
|
|
48
|
+
}) => ReactNode;
|
|
49
|
+
|
|
71
50
|
}
|
|
@@ -1,121 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { CodeTokenType } from "../semantic/types.ts";
|
|
1
|
+
import { tokenizeJsonValue, type JsonToken } from "../utils/jsonTokenizer.ts";
|
|
2
|
+
import { SemanticColors } from "../theme.ts";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* JSON syntax highlighting
|
|
5
|
+
* JSON syntax highlighting utility.
|
|
6
|
+
*
|
|
7
|
+
* This is intentionally kept under `src/tui/components/*` because it can be used
|
|
8
|
+
* by external apps importing from this package.
|
|
9
|
+
*
|
|
10
|
+
* Returns an ANSI-colored string suitable for terminal output.
|
|
7
11
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function tokenizeJson(value: unknown, indent = 0): JsonLineTokens[] {
|
|
14
|
-
const pad = " ".repeat(indent);
|
|
15
|
-
const padToken = (): JsonToken => ({ type: "punctuation", value: pad });
|
|
12
|
+
export interface JsonHighlightProps {
|
|
13
|
+
value: unknown;
|
|
14
|
+
}
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
function getTokenColor(type: JsonToken["type"]): string {
|
|
17
|
+
switch (type) {
|
|
18
|
+
case "punctuation":
|
|
19
|
+
return SemanticColors.mutedText;
|
|
20
|
+
case "key":
|
|
21
|
+
return SemanticColors.primary;
|
|
22
|
+
case "string":
|
|
23
|
+
return SemanticColors.success;
|
|
24
|
+
case "number":
|
|
25
|
+
return SemanticColors.warning;
|
|
26
|
+
case "boolean":
|
|
27
|
+
return SemanticColors.primary;
|
|
28
|
+
case "null":
|
|
29
|
+
return SemanticColors.mutedText;
|
|
28
30
|
}
|
|
29
|
-
if (Array.isArray(value)) {
|
|
30
|
-
if (value.length === 0) {
|
|
31
|
-
return [[padToken(), { type: "punctuation", value: "[]" }]];
|
|
32
|
-
}
|
|
33
|
-
const lines: JsonLineTokens[] = [[padToken(), { type: "punctuation", value: "[" }]];
|
|
34
|
-
value.forEach((item, idx) => {
|
|
35
|
-
const itemLines = tokenizeJson(item, indent + 1);
|
|
36
|
-
const isLast = idx === value.length - 1;
|
|
37
|
-
itemLines.forEach((line, lineIdx) => {
|
|
38
|
-
if (lineIdx === itemLines.length - 1 && !isLast) {
|
|
39
|
-
lines.push([...line, { type: "punctuation", value: "," }]);
|
|
40
|
-
} else {
|
|
41
|
-
lines.push(line);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
lines.push([padToken(), { type: "punctuation", value: "]" }]);
|
|
46
|
-
return lines;
|
|
47
|
-
}
|
|
48
|
-
if (typeof value === "object") {
|
|
49
|
-
const entries = Object.entries(value);
|
|
50
|
-
if (entries.length === 0) {
|
|
51
|
-
return [[padToken(), { type: "punctuation", value: "{}" }]];
|
|
52
|
-
}
|
|
53
|
-
const lines: JsonLineTokens[] = [[padToken(), { type: "punctuation", value: "{" }]];
|
|
54
|
-
const innerPad = " ".repeat(indent + 1);
|
|
55
|
-
|
|
56
|
-
entries.forEach(([key, val], idx) => {
|
|
57
|
-
const valLines = tokenizeJson(val, indent + 1);
|
|
58
|
-
const isLast = idx === entries.length - 1;
|
|
59
|
-
|
|
60
|
-
// First value line - prepend key
|
|
61
|
-
const firstValLine = valLines[0] ?? [];
|
|
62
|
-
// Remove the padding from value's first line (we'll add our own with the key)
|
|
63
|
-
const valTokens = firstValLine.filter(t => t.value !== " ".repeat(indent + 1));
|
|
64
|
-
|
|
65
|
-
const keyLine: JsonLineTokens = [
|
|
66
|
-
{ type: "punctuation", value: innerPad },
|
|
67
|
-
{ type: "key", value: `"${key}"` },
|
|
68
|
-
{ type: "punctuation", value: ": " },
|
|
69
|
-
...valTokens,
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
if (valLines.length === 1) {
|
|
73
|
-
// Single line value
|
|
74
|
-
if (!isLast) keyLine.push({ type: "punctuation", value: "," });
|
|
75
|
-
lines.push(keyLine);
|
|
76
|
-
} else {
|
|
77
|
-
// Multi-line value
|
|
78
|
-
lines.push(keyLine);
|
|
79
|
-
valLines.slice(1, -1).forEach(line => lines.push(line));
|
|
80
|
-
const lastLine = valLines[valLines.length - 1] ?? [];
|
|
81
|
-
if (!isLast) {
|
|
82
|
-
lines.push([...lastLine, { type: "punctuation", value: "," }]);
|
|
83
|
-
} else {
|
|
84
|
-
lines.push(lastLine);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
lines.push([padToken(), { type: "punctuation", value: "}" }]);
|
|
89
|
-
return lines;
|
|
90
|
-
}
|
|
91
|
-
return [];
|
|
92
31
|
}
|
|
93
32
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
33
|
+
function toAnsiColor(hex: string): string {
|
|
34
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
35
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
36
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
37
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
97
38
|
}
|
|
98
39
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const lines = tokenizeJson(value);
|
|
111
|
-
return (
|
|
112
|
-
<Container flexDirection="column" gap={0}>
|
|
113
|
-
{lines.map((tokens, lineIdx) => (
|
|
114
|
-
<CodeHighlight
|
|
115
|
-
key={`json-${lineIdx}`}
|
|
116
|
-
tokens={tokens.map((token) => ({ type: token.type, value: token.value }))}
|
|
117
|
-
/>
|
|
118
|
-
))}
|
|
119
|
-
</Container>
|
|
120
|
-
);
|
|
40
|
+
const RESET = "\x1b[0m";
|
|
41
|
+
|
|
42
|
+
export function JsonHighlight({ value }: JsonHighlightProps): string {
|
|
43
|
+
const lines = tokenizeJsonValue(value);
|
|
44
|
+
|
|
45
|
+
return lines.map((tokens) =>
|
|
46
|
+
tokens.map((token) => {
|
|
47
|
+
const color = getTokenColor(token.type);
|
|
48
|
+
return `${toAnsiColor(color)}${token.value}${RESET}`;
|
|
49
|
+
}).join("")
|
|
50
|
+
).join("\n");
|
|
121
51
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
import type { NavigationAPI } from "./NavigationContext.tsx";
|
|
3
|
+
import type { TuiAction } from "../actions.ts";
|
|
4
|
+
|
|
5
|
+
export type TuiActionDispatcher = (action: TuiAction) => void;
|
|
6
|
+
|
|
7
|
+
interface ActionContextValue {
|
|
8
|
+
dispatchAction: TuiActionDispatcher;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ActionContext = createContext<ActionContextValue | null>(null);
|
|
12
|
+
|
|
13
|
+
export function ActionProvider({
|
|
14
|
+
children,
|
|
15
|
+
navigation,
|
|
16
|
+
}: {
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
navigation: NavigationAPI;
|
|
19
|
+
}) {
|
|
20
|
+
const dispatchAction = useMemo<TuiActionDispatcher>(() => {
|
|
21
|
+
return (action) => {
|
|
22
|
+
if (action.type === "nav.back") {
|
|
23
|
+
navigation.goBack();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if (action.type === "logs.open") {
|
|
29
|
+
// Prevent stacking: only open if logs modal is not already open
|
|
30
|
+
const isLogsAlreadyOpen = navigation.modalStack.some((m) => m.id === "logs");
|
|
31
|
+
if (!isLogsAlreadyOpen) {
|
|
32
|
+
navigation.openModal("logs");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}, [navigation]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<ActionContext.Provider value={{ dispatchAction }}>
|
|
40
|
+
{children}
|
|
41
|
+
</ActionContext.Provider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useAction(): ActionContextValue {
|
|
46
|
+
const context = useContext(ActionContext);
|
|
47
|
+
if (!context) {
|
|
48
|
+
throw new Error("useAction must be used within an ActionProvider");
|
|
49
|
+
}
|
|
50
|
+
return context;
|
|
51
|
+
}
|
|
@@ -26,6 +26,8 @@ export interface ExecutionOutcome {
|
|
|
26
26
|
export interface ExecutorContextValue {
|
|
27
27
|
/** Whether a command is currently executing */
|
|
28
28
|
isExecuting: boolean;
|
|
29
|
+
/** Whether a cancellation has been requested */
|
|
30
|
+
isCancelling: boolean;
|
|
29
31
|
/** Execute a command with the given values */
|
|
30
32
|
execute: (command: AnyCommand, values: Record<string, unknown>) => Promise<ExecutionOutcome>;
|
|
31
33
|
/** Cancel the currently executing command */
|
|
@@ -46,6 +48,7 @@ interface ExecutorProviderProps {
|
|
|
46
48
|
*/
|
|
47
49
|
export function ExecutorProvider({ children }: ExecutorProviderProps) {
|
|
48
50
|
const [isExecuting, setIsExecuting] = useState(false);
|
|
51
|
+
const [isCancelling, setIsCancelling] = useState(false);
|
|
49
52
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
50
53
|
|
|
51
54
|
const execute = useCallback(async (
|
|
@@ -98,6 +101,7 @@ export function ExecutorProvider({ children }: ExecutorProviderProps) {
|
|
|
98
101
|
return { success: false, error };
|
|
99
102
|
} finally {
|
|
100
103
|
setIsExecuting(false);
|
|
104
|
+
setIsCancelling(false);
|
|
101
105
|
if (abortControllerRef.current === abortController) {
|
|
102
106
|
abortControllerRef.current = null;
|
|
103
107
|
}
|
|
@@ -106,6 +110,7 @@ export function ExecutorProvider({ children }: ExecutorProviderProps) {
|
|
|
106
110
|
|
|
107
111
|
const cancel = useCallback(() => {
|
|
108
112
|
if (abortControllerRef.current) {
|
|
113
|
+
setIsCancelling(true);
|
|
109
114
|
abortControllerRef.current.abort();
|
|
110
115
|
abortControllerRef.current = null;
|
|
111
116
|
}
|
|
@@ -117,10 +122,11 @@ export function ExecutorProvider({ children }: ExecutorProviderProps) {
|
|
|
117
122
|
abortControllerRef.current = null;
|
|
118
123
|
}
|
|
119
124
|
setIsExecuting(false);
|
|
125
|
+
setIsCancelling(false);
|
|
120
126
|
}, []);
|
|
121
127
|
|
|
122
128
|
return (
|
|
123
|
-
<ExecutorContext.Provider value={{ isExecuting, execute, cancel, reset }}>
|
|
129
|
+
<ExecutorContext.Provider value={{ isExecuting, isCancelling, execute, cancel, reset }}>
|
|
124
130
|
{children}
|
|
125
131
|
</ExecutorContext.Provider>
|
|
126
132
|
);
|