@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
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@pablozaiden/terminatui",
3
- "version": "0.3.0-beta-1",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal UI and Command Line Application Framework",
5
5
  "repository": {
6
6
  "url": "https://github.com/PabloZaiden/terminatui"
7
7
  },
8
8
  "type": "module",
9
- "main": "src/index.ts",
10
- "types": "src/index.ts",
9
+ "files": [
10
+ "src/**/*"
11
+ ],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "default": "./src/index.ts"
16
+ }
17
+ },
11
18
  "scripts": {
12
19
  "build": "bunx tsc --noEmit",
13
20
  "test": "bun test",
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test";
2
+ import { Glob } from "bun";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const adaptersDir = fileURLToPath(new URL("../tui/adapters", import.meta.url));
6
+
7
+ async function getAdapterSourceFiles(): Promise<string[]> {
8
+ const glob = new Glob("**/*.{ts,tsx}");
9
+ const files: string[] = [];
10
+ for await (const file of glob.scan({ cwd: adaptersDir, absolute: true })) {
11
+ // skip shared behavior (allowed)
12
+ if (!file.includes("/shared/")) {
13
+ files.push(file);
14
+ }
15
+ }
16
+ return files;
17
+ }
18
+
19
+ test("Adapters must not import src/tui/components/*", async () => {
20
+ const files = await getAdapterSourceFiles();
21
+ expect(files.length).toBeGreaterThan(0);
22
+
23
+ const forbidden = /from\s+['"][^'"]*tui\/components\//;
24
+
25
+ for (const filePath of files) {
26
+ const source = await Bun.file(filePath).text();
27
+ const match = source.match(forbidden);
28
+ if (match) {
29
+ throw new Error(
30
+ `Adapter file imports shared component:\n File: ${filePath}\n Match: ${match[0]}`
31
+ );
32
+ }
33
+ }
34
+ });
@@ -0,0 +1,63 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Command } from "../core/command.ts";
3
+ import type { OptionSchema } from "../types/command.ts";
4
+
5
+ class TestCommand extends Command<typeof TestCommand.Options> {
6
+ static readonly Options = {
7
+ a: { type: "string" as const, description: "a" },
8
+ b: { type: "string" as const, description: "b" },
9
+ } as const satisfies OptionSchema;
10
+
11
+ readonly name = "my";
12
+ readonly description = "my";
13
+ readonly options = TestCommand.Options;
14
+
15
+ public readonly onChangeCalls: Array<[
16
+ string,
17
+ unknown,
18
+ Record<string, unknown>
19
+ ]> = [];
20
+
21
+ override execute(): void {}
22
+
23
+ override onConfigChange(
24
+ key: string,
25
+ value: unknown,
26
+ allValues: Record<string, unknown>
27
+ ) {
28
+ this.onChangeCalls.push([key, value, allValues]);
29
+ if (key === "a") {
30
+ return { b: "derived" };
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ applyTuiConfigChange(
36
+ key: string,
37
+ value: unknown,
38
+ values: Record<string, unknown>
39
+ ): Record<string, unknown> {
40
+ let nextValues: Record<string, unknown> = { ...values, [key]: value };
41
+
42
+ const updates = this.onConfigChange?.(key, value, nextValues);
43
+ if (updates && typeof updates === "object") {
44
+ nextValues = { ...nextValues, ...updates };
45
+ }
46
+
47
+ return nextValues;
48
+ }
49
+ }
50
+
51
+ test("onConfigChange merges returned updates", () => {
52
+ const command = new TestCommand();
53
+
54
+ const next = command.applyTuiConfigChange("a", "new", {
55
+ a: "old",
56
+ b: "oldb",
57
+ });
58
+
59
+ expect(command.onChangeCalls.length).toBe(1);
60
+ expect(command.onChangeCalls[0]?.[0]).toBe("a");
61
+ expect(command.onChangeCalls[0]?.[1]).toBe("new");
62
+ expect(next).toEqual({ a: "new", b: "derived" });
63
+ });
@@ -21,8 +21,6 @@ describe("schemaToFields", () => {
21
21
  label: "Repository",
22
22
  },
23
23
  hidden: { type: "string", description: "Hidden", tuiHidden: true },
24
- path: { type: "string", description: "Path", placeholder: "Enter path here" },
25
- grouped: { type: "string", description: "Grouped", group: "Basic" },
26
24
  };
27
25
 
28
26
  const fields = schemaToFieldConfigs(schema);
@@ -42,8 +40,6 @@ describe("schemaToFields", () => {
42
40
  // Decorations
43
41
  expect(fields.find((f) => f.key === "repoPath")?.label).toBe("Repository");
44
42
  expect(fields.some((f) => f.key === "hidden")).toBe(false);
45
- expect(fields.find((f) => f.key === "path")?.placeholder).toBe("Enter path here");
46
- expect(fields.find((f) => f.key === "grouped")?.group).toBe("Basic");
47
43
  });
48
44
 
49
45
  test("sorts by order", () => {
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ const tuiRootPath = new URL("../tui/TuiRoot.tsx", import.meta.url);
4
+
5
+ async function readTuiRootSource() {
6
+ return await Bun.file(tuiRootPath).text();
7
+ }
8
+
9
+ test("TuiRoot stays a thin host (no domain helper imports)", async () => {
10
+ const source = await readTuiRootSource();
11
+
12
+ const forbiddenFragments = [
13
+ "buildCliCommand",
14
+ "schemaToFieldConfigs",
15
+ "schemaToFields",
16
+ "initializeConfigValues",
17
+ "loadPersistedParameters",
18
+ "savePersistedParameters",
19
+ "getCommandsAtPath",
20
+ ];
21
+
22
+ for (const fragment of forbiddenFragments) {
23
+ expect(source).not.toContain(fragment);
24
+ }
25
+ });
@@ -18,7 +18,7 @@ interface VersionConfig {
18
18
  * Format version string with optional commit hash.
19
19
  * If commitHash is empty or undefined, shows "(dev)".
20
20
  */
21
- function formatVersion(version: string, commitHash?: string): string {
21
+ export function formatVersion(version: string, commitHash?: string): string {
22
22
  const hashPart = commitHash && commitHash.length > 0
23
23
  ? commitHash.substring(0, 7)
24
24
  : "(dev)";
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export * from "./builtins/help.ts";
2
+ export * from "./builtins/settings.ts";
3
+ export * from "./builtins/version.ts";
4
+
5
+ export * from "./cli/parser.ts";
6
+ export * from "./cli/output/colors.ts";
7
+
8
+ export * from "./core/application.ts";
9
+ export * from "./core/command.ts";
10
+ export * from "./core/context.ts";
11
+ export * from "./core/help.ts";
12
+ export * from "./core/knownCommands.ts";
13
+ export * from "./core/logger.ts";
14
+ export * from "./core/registry.ts";
15
+
16
+ export * from "./tui/TuiApplication.tsx";
17
+ export * from "./tui/TuiRoot.tsx";
18
+ export * from "./tui/theme.ts";
19
+ export * from "./types/command.ts";
20
+
21
+ // Kept for external consumers.
22
+ export * from "./tui/components/JsonHighlight.tsx";
@@ -8,7 +8,6 @@ import { createSettingsCommand } from "../builtins/settings.ts";
8
8
  import { KNOWN_COMMANDS } from "../core/knownCommands.ts";
9
9
  import { loadPersistedParameters } from "./utils/parameterPersistence.ts";
10
10
  import { AppContext } from "../core/context.ts";
11
- import { registerAllModals, registerAllScreens } from "./registry.ts";
12
11
 
13
12
  /**
14
13
  * Extended configuration for TUI-enabled applications.
@@ -87,9 +86,6 @@ export class TuiApplication extends Application {
87
86
  * Launch the TUI.
88
87
  */
89
88
  async runTui(rendererType: TuiModeOptions): Promise<void> {
90
- await registerAllScreens();
91
- await registerAllModals();
92
-
93
89
  // Get all commands that support TUI or have options
94
90
  const commands = this.getExecutableCommands();
95
91
 
@@ -1,18 +1,14 @@
1
1
  import type { AnyCommand } from "../core/command.ts";
2
- import { useClipboard } from "./hooks/useClipboard.ts";
3
- import { KeyboardProvider } from "./context/KeyboardContext.tsx";
4
- import { useGlobalKeyHandler } from "./hooks/useGlobalKeyHandler.ts";
2
+ import { useState } from "react";
5
3
  import { LogsProvider } from "./context/LogsContext.tsx";
6
4
  import { NavigationProvider, useNavigation } from "./context/NavigationContext.tsx";
7
- import { ClipboardProviderComponent, useClipboardContext } from "./context/ClipboardContext.tsx";
8
5
  import { TuiAppContextProvider, useTuiApp } from "./context/TuiAppContext.tsx";
9
- import { ExecutorProvider, useExecutor } from "./context/ExecutorContext.tsx";
10
- import { Header } from "./components/Header.tsx";
11
- import { StatusBar } from "./components/StatusBar.tsx";
12
- import { Container } from "./semantic/Container.tsx";
13
- import { Panel } from "./semantic/Panel.tsx";
14
- import { getScreen, getModal } from "./registry.ts";
15
- import { CommandSelectScreen, type CommandSelectParams } from "./screens/CommandSelectScreen.tsx";
6
+ import { ExecutorProvider } from "./context/ExecutorContext.tsx";
7
+ import { ActionProvider, useAction } from "./context/ActionContext.tsx";
8
+ import { useRenderer } from "./context/RendererContext.tsx";
9
+
10
+ import { TuiDriverProvider, useTuiDriver } from "./driver/context/TuiDriverContext.tsx";
11
+
16
12
 
17
13
  interface TuiRootProps {
18
14
  name: string;
@@ -24,28 +20,26 @@ interface TuiRootProps {
24
20
 
25
21
  export function TuiRoot({ name, displayName, version, commands, onExit }: TuiRootProps) {
26
22
  return (
27
- <KeyboardProvider>
28
- <ClipboardProviderComponent>
29
- <TuiAppContextProvider
30
- name={name}
31
- displayName={displayName}
32
- version={version}
33
- commands={commands}
34
- onExit={onExit}
35
- >
36
- <LogsProvider>
37
- <ExecutorProvider>
38
- <NavigationProvider<CommandSelectParams>
39
- initialScreen={{ route: CommandSelectScreen.Id, params: { commandPath: [] } }}
40
- onExit={onExit}
41
- >
42
- <TuiRootContent />
43
- </NavigationProvider>
44
- </ExecutorProvider>
45
- </LogsProvider>
46
- </TuiAppContextProvider>
47
- </ClipboardProviderComponent>
48
- </KeyboardProvider>
23
+ <TuiAppContextProvider
24
+ name={name}
25
+ displayName={displayName}
26
+ version={version}
27
+ commands={commands}
28
+ onExit={onExit}
29
+ >
30
+ <LogsProvider>
31
+ <ExecutorProvider>
32
+ <NavigationProvider<{ commandPath: string[] }>
33
+ initialScreen={{ route: "commandBrowser", params: { commandPath: [] as string[] } }}
34
+ onExit={onExit}
35
+ >
36
+ <TuiDriverProvider appName={name} commands={commands}>
37
+ <TuiRootContent />
38
+ </TuiDriverProvider>
39
+ </NavigationProvider>
40
+ </ExecutorProvider>
41
+ </LogsProvider>
42
+ </TuiAppContextProvider>
49
43
  );
50
44
  }
51
45
 
@@ -55,81 +49,43 @@ export function TuiRoot({ name, displayName, version, commands, onExit }: TuiRoo
55
49
  */
56
50
  function TuiRootContent() {
57
51
  const { displayName, name, version } = useTuiApp();
52
+ const driver = useTuiDriver();
58
53
  const navigation = useNavigation();
59
- const executor = useExecutor();
60
- const clipboard = useClipboardContext();
61
- const { copyWithMessage, lastAction } = useClipboard();
62
-
63
-
54
+ const [copyToast, setCopyToast] = useState<string | null>(null);
64
55
 
65
- // Global keyboard handler - only truly global shortcuts
66
- useGlobalKeyHandler((key) => {
67
- // Esc - back/close (delegates to navigation which delegates to screen)
68
- if (key.name === "escape") {
69
- navigation.goBack();
70
- return true;
71
- }
72
-
73
- // Ctrl+Y - copy
74
- if (key.ctrl && key.name === "y") {
75
- const content = clipboard.getContent();
76
- if (content) {
77
- copyWithMessage(content.content, content.label);
78
- }
79
- return true;
80
- }
56
+ return (
57
+ <ActionProvider navigation={navigation}>
58
+ <TuiRootKeyboardHandler onCopyToastChange={setCopyToast} />
59
+ {driver.renderAppShell({
60
+ app: {
61
+ name,
62
+ displayName,
63
+ version,
64
+ },
65
+ copyToast,
66
+ })}
67
+ </ActionProvider>
68
+ );
69
+ }
81
70
 
82
- // Ctrl+L - toggle logs modal
83
- if (key.ctrl && key.name === "l") {
84
- const isLogsOpen = navigation.modalStack.some((m) => m.id === "logs");
85
- if (isLogsOpen) {
86
- navigation.closeModal();
87
- } else {
88
- navigation.openModal("logs");
71
+ /**
72
+ * Renders the adapter-specific keyboard handler component.
73
+ * This component uses hooks properly since it's rendered as a React component.
74
+ */
75
+ function TuiRootKeyboardHandler({ onCopyToastChange }: { onCopyToastChange: (toast: string | null) => void }) {
76
+ const renderer = useRenderer();
77
+ const { dispatchAction } = useAction();
89
78
 
90
- }
91
- return true;
92
- }
79
+ if (!renderer.renderKeyboardHandler) {
80
+ return null;
81
+ }
93
82
 
94
- return false;
83
+ return renderer.renderKeyboardHandler({
84
+ dispatchAction,
85
+ getScreenKeyHandler: () => null,
86
+ onCopyToastChange,
95
87
  });
88
+ }
96
89
 
97
- // Get current screen component from registry
98
- const ScreenComponent = getScreen(navigation.current.route);
99
-
100
- // Get breadcrumb from current screen params (if available)
101
- const params = navigation.current.params as { commandPath?: string[] } | undefined;
102
- const breadcrumb = params?.commandPath;
103
-
104
- return (
105
- <Panel flexDirection="column" flex={1} padding={1} border={false}>
106
- <Container flexDirection="column" flex={1}>
107
- <Header name={displayName ?? name} version={version} breadcrumb={breadcrumb} />
108
-
109
- <Container flexDirection="column" flex={1}>
110
- {ScreenComponent ? <ScreenComponent /> : null}
111
- </Container>
112
-
113
- <StatusBar
114
- status={lastAction || (executor.isExecuting ? "Executing..." : "Ready")}
115
- isRunning={executor.isExecuting}
116
- shortcuts="Esc Back • Ctrl+Y Copy • Ctrl+L Logs"
117
- />
118
90
 
119
- {/* Render modals from registry */}
120
- {navigation.modalStack.map((modal, idx) => {
121
- const ModalComponent = getModal(modal.id);
122
- if (!ModalComponent) return null;
123
91
 
124
- return (
125
- <ModalComponent
126
- key={`modal-${modal.id}-${idx}`}
127
- params={modal.params}
128
- onClose={() => navigation.closeModal()}
129
- />
130
- );
131
- })}
132
- </Container>
133
- </Panel>
134
- );
135
- }
@@ -0,0 +1,4 @@
1
+ export type TuiAction =
2
+ | { type: "nav.back" }
3
+ | { type: "copy" }
4
+ | { type: "logs.open" };
@@ -1,28 +1,180 @@
1
1
  import { render } from "ink";
2
2
  import type { ReactNode } from "react";
3
- import { useLayoutEffect } from "react";
3
+ import { useEffect, useLayoutEffect } from "react";
4
4
 
5
- import type { Renderer, RendererConfig } from "../types.ts";
5
+ import type { KeyboardEvent, Renderer, RendererConfig } from "../types.ts";
6
+ import { SemanticInkRenderer } from "./SemanticInkRenderer.tsx";
6
7
  import { useInkKeyboardAdapter } from "./keyboard.ts";
8
+ import { copyToTerminalClipboard } from "../shared/TerminalClipboard.ts";
9
+ import { useTuiDriver } from "../../driver/context/TuiDriverContext.tsx";
7
10
 
8
- import { Button } from "./components/Button.tsx";
9
- import { Container } from "./components/Container.tsx";
10
- import { Field } from "./components/Field.tsx";
11
- import { Label } from "./components/Label.tsx";
12
- import { MenuButton } from "./components/MenuButton.tsx";
13
- import { MenuItem } from "./components/MenuItem.tsx";
14
- import { Overlay } from "./components/Overlay.tsx";
15
- import { Panel } from "./components/Panel.tsx";
16
- import { ScrollView } from "./components/ScrollView.tsx";
17
- import { Select } from "./components/Select.tsx";
18
- import { Spacer } from "./components/Spacer.tsx";
19
- import { Spinner } from "./components/Spinner.tsx";
20
- import { TextInput } from "./components/TextInput.tsx";
21
- import { Value } from "./components/Value.tsx";
22
- import { Code } from "./components/Code.tsx";
23
- import { CodeHighlight } from "./components/CodeHighlight.tsx";
11
+ import type { TuiAction } from "../../actions.ts";
12
+
13
+ function InkKeyboardHandler({
14
+ dispatchAction,
15
+ getScreenKeyHandler,
16
+ keyboard,
17
+ onCopyToastChange,
18
+ }: {
19
+ dispatchAction: (action: TuiAction) => void;
20
+ getScreenKeyHandler: () => ((event: KeyboardEvent) => boolean) | null;
21
+ keyboard: Renderer["keyboard"];
22
+ onCopyToastChange?: (toast: string | null) => void;
23
+ }) {
24
+ const driver = useTuiDriver();
25
+
26
+ useEffect(() => {
27
+ const cleanup = keyboard.setGlobalHandler((event) => {
28
+ // Debug: log keyboard events
29
+ // console.log("Key event:", event.name, "screenHandler:", getScreenKeyHandler() ? "yes" : "no");
30
+
31
+ if (event.name === "escape") {
32
+ dispatchAction({ type: "nav.back" });
33
+ return true;
34
+ }
35
+
36
+ if (event.ctrl && event.name === "y") {
37
+ const payload = driver.getActiveCopyPayload();
38
+ if (payload) {
39
+ // Show toast immediately for instant feedback
40
+ onCopyToastChange?.(`Copied ${payload.label}`);
41
+ void copyToTerminalClipboard(payload.content).then((success) => {
42
+ if (!success) {
43
+ onCopyToastChange?.("Copy failed");
44
+ }
45
+ setTimeout(() => onCopyToastChange?.(null), 1500);
46
+ });
47
+ }
48
+ return true;
49
+ }
50
+
51
+ if (event.ctrl && event.name === "l") {
52
+ dispatchAction({ type: "logs.open" });
53
+ return true;
54
+ }
55
+
56
+ const screenHandler = getScreenKeyHandler();
57
+ if (screenHandler) {
58
+ return screenHandler(event);
59
+ }
60
+
61
+ return false;
62
+ });
63
+
64
+ return cleanup;
65
+ }, [dispatchAction, getScreenKeyHandler, keyboard, driver, onCopyToastChange]);
66
+
67
+ return null;
68
+ }
24
69
 
25
70
  export class InkRenderer implements Renderer {
71
+
72
+ private readonly semanticRenderer = new SemanticInkRenderer();
73
+
74
+ private semanticScreenKeyHandler: ((event: KeyboardEvent) => boolean) | null = null;
75
+
76
+ public renderSemanticAppShell: Renderer["renderSemanticAppShell"] = (props) => {
77
+ return this.semanticRenderer.renderAppShell(props);
78
+ };
79
+ public renderSemanticCommandBrowserScreen: Renderer["renderSemanticCommandBrowserScreen"] = (props) => {
80
+ this.semanticScreenKeyHandler = (event) => {
81
+ if (event.ctrl && event.name === "l") {
82
+ // Logs open is adapter-owned.
83
+ return false;
84
+ }
85
+
86
+ if (event.name === "up") {
87
+ props.onSelectCommand(props.selectedCommandIndex - 1);
88
+ return true;
89
+ }
90
+
91
+ if (event.name === "down") {
92
+ props.onSelectCommand(props.selectedCommandIndex + 1);
93
+ return true;
94
+ }
95
+
96
+ if (event.name === "return") {
97
+ props.onRunSelected();
98
+ return true;
99
+ }
100
+
101
+ return false;
102
+ };
103
+
104
+ return this.semanticRenderer.renderCommandBrowserScreen(props);
105
+ };
106
+
107
+ public renderSemanticConfigScreen: Renderer["renderSemanticConfigScreen"] = (props) => {
108
+ this.semanticScreenKeyHandler = (event) => {
109
+ if (event.ctrl && event.name === "l") {
110
+ // Adapter-owned logs open.
111
+ return false;
112
+ }
113
+
114
+ if (event.name === "up") {
115
+ props.onSelectionChange(props.selectedFieldIndex - 1);
116
+ return true;
117
+ }
118
+
119
+ if (event.name === "down") {
120
+ props.onSelectionChange(props.selectedFieldIndex + 1);
121
+ return true;
122
+ }
123
+
124
+ if (event.name === "return") {
125
+ const fieldConfig = props.fieldConfigs[props.selectedFieldIndex];
126
+ if (fieldConfig) {
127
+ props.onEditField(fieldConfig.key);
128
+ } else {
129
+ props.onRun();
130
+ }
131
+ return true;
132
+ }
133
+
134
+ return false;
135
+ };
136
+
137
+ return this.semanticRenderer.renderConfigScreen(props);
138
+ };
139
+ public renderSemanticRunningScreen: Renderer["renderSemanticRunningScreen"] = (props) => {
140
+ this.semanticScreenKeyHandler = null;
141
+ return this.semanticRenderer.renderRunningScreen(props);
142
+ };
143
+ public renderSemanticLogsScreen: Renderer["renderSemanticLogsScreen"] = (props) => {
144
+ this.semanticScreenKeyHandler = (event) => {
145
+ if (event.name === "return") {
146
+ props.onClose();
147
+ return true;
148
+ }
149
+ return false;
150
+ };
151
+
152
+ return this.semanticRenderer.renderLogsScreen(props);
153
+ };
154
+
155
+ public renderSemanticEditorScreen: Renderer["renderSemanticEditorScreen"] = (props) => {
156
+ this.semanticScreenKeyHandler = (event) => {
157
+ if (event.name === "return") {
158
+ props.onSubmit?.();
159
+ return true;
160
+ }
161
+ return false;
162
+ };
163
+
164
+ return this.semanticRenderer.renderEditorScreen(props);
165
+ };
166
+
167
+ public renderKeyboardHandler: Renderer["renderKeyboardHandler"] = ({ dispatchAction, onCopyToastChange }) => {
168
+ return (
169
+ <InkKeyboardHandlerWrapper
170
+ dispatchAction={dispatchAction}
171
+ getScreenKeyHandler={() => this.semanticScreenKeyHandler}
172
+ keyboard={this.keyboard}
173
+ onCopyToastChange={onCopyToastChange}
174
+ />
175
+ );
176
+ };
177
+
26
178
  private instance: ReturnType<typeof render> | null = null;
27
179
  private activeKeyboardAdapter: Renderer["keyboard"] | null = null;
28
180
 
@@ -35,29 +187,6 @@ export class InkRenderer implements Renderer {
35
187
  },
36
188
  };
37
189
 
38
- public components: Renderer["components"] = {
39
- Field,
40
- Button,
41
- MenuButton,
42
- MenuItem,
43
-
44
- Container,
45
- Panel,
46
- ScrollView,
47
-
48
- Overlay,
49
- Spacer,
50
- Spinner,
51
-
52
- Label,
53
- Value,
54
- Code,
55
- CodeHighlight,
56
-
57
- Select,
58
- TextInput,
59
- };
60
-
61
190
  constructor(_config: RendererConfig) {}
62
191
 
63
192
  async initialize(): Promise<void> {
@@ -133,3 +262,24 @@ function KeyboardBridge({
133
262
 
134
263
  return node;
135
264
  }
265
+
266
+ function InkKeyboardHandlerWrapper({
267
+ dispatchAction,
268
+ getScreenKeyHandler,
269
+ keyboard,
270
+ onCopyToastChange,
271
+ }: {
272
+ dispatchAction: (action: TuiAction) => void;
273
+ getScreenKeyHandler: () => ((event: KeyboardEvent) => boolean) | null;
274
+ keyboard: Renderer["keyboard"];
275
+ onCopyToastChange?: (toast: string | null) => void;
276
+ }) {
277
+ return (
278
+ <InkKeyboardHandler
279
+ dispatchAction={dispatchAction}
280
+ getScreenKeyHandler={getScreenKeyHandler}
281
+ keyboard={keyboard}
282
+ onCopyToastChange={onCopyToastChange}
283
+ />
284
+ );
285
+ }