@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,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
- ButtonProps,
4
- CodeHighlightProps,
5
- CodeProps,
6
- ContainerProps,
7
- FieldProps,
8
- LabelProps,
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,34 +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
30
 
68
31
  keyboard: KeyboardAdapter;
69
- components: RendererComponents;
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
+
70
50
  }
@@ -1,121 +1,51 @@
1
- import { Container } from "../semantic/Container.tsx";
2
- import { CodeHighlight } from "../semantic/CodeHighlight.tsx";
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 types and colors
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
- type JsonTokenType = Exclude<CodeTokenType, "unknown">;
9
- type JsonToken = { type: JsonTokenType; value: string };
10
- type JsonLineTokens = JsonToken[];
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
- if (value === null) {
18
- return [[padToken(), { type: "null", value: "null" }]];
19
- }
20
- if (typeof value === "boolean") {
21
- return [[padToken(), { type: "boolean", value: String(value) }]];
22
- }
23
- if (typeof value === "number") {
24
- return [[padToken(), { type: "number", value: String(value) }]];
25
- }
26
- if (typeof value === "string") {
27
- return [[padToken(), { type: "string", value: JSON.stringify(value) }]];
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
- export interface JsonHighlightProps {
95
- /** The value to render as syntax-highlighted JSON */
96
- value: unknown;
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
- * Render JSON with syntax highlighting.
101
- *
102
- * Tokenizes the JSON value and renders each token with appropriate colors:
103
- * - Keys: blue
104
- * - Strings: green
105
- * - Numbers: orange
106
- * - Booleans/null: purple
107
- * - Punctuation: theme label color
108
- */
109
- export function JsonHighlight({ value }: JsonHighlightProps) {
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
  );