@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
@@ -8,8 +8,8 @@ import {
8
8
  type ReactNode,
9
9
  } from "react";
10
10
 
11
- export interface ScreenEntry<TParams = unknown> {
12
- route: string;
11
+ export interface ScreenEntry<TRoute extends string = string, TParams = unknown> {
12
+ route: TRoute;
13
13
  params?: TParams;
14
14
  meta?: { focus?: string; breadcrumb?: string[] };
15
15
  }
@@ -38,6 +38,8 @@ export interface NavigationAPI {
38
38
  currentModal?: ModalEntry;
39
39
  openModal: <TParams>(id: string, params?: TParams) => void;
40
40
  closeModal: () => void;
41
+ /** Update the topmost modal's params (for reactive state updates) */
42
+ updateModal: <TParams>(params: TParams) => void;
41
43
  hasModal: boolean;
42
44
 
43
45
  /**
@@ -57,7 +59,7 @@ export interface NavigationAPI {
57
59
  }
58
60
 
59
61
  type NavigationProviderProps<TParams = unknown> = {
60
- initialScreen: ScreenEntry<TParams>;
62
+ initialScreen: ScreenEntry<string, TParams>;
61
63
  children: ReactNode;
62
64
  /** Called when we can't go back anymore (at root with empty stack) */
63
65
  onExit?: () => void;
@@ -69,7 +71,8 @@ type NavigationAction =
69
71
  | { type: "reset"; screen: ScreenEntry }
70
72
  | { type: "pop" }
71
73
  | { type: "openModal"; modal: ModalEntry }
72
- | { type: "closeModal" };
74
+ | { type: "closeModal" }
75
+ | { type: "updateModal"; params: unknown };
73
76
 
74
77
  type NavigationState = {
75
78
  stack: ScreenEntry[];
@@ -101,6 +104,17 @@ function navigationReducer(
101
104
  if (state.modalStack.length === 0) return state;
102
105
  return { ...state, modalStack: state.modalStack.slice(0, -1) };
103
106
  }
107
+ case "updateModal": {
108
+ if (state.modalStack.length === 0) return state;
109
+ const updatedModal = {
110
+ ...state.modalStack[state.modalStack.length - 1]!,
111
+ params: action.params,
112
+ };
113
+ return {
114
+ ...state,
115
+ modalStack: [...state.modalStack.slice(0, -1), updatedModal],
116
+ };
117
+ }
104
118
  default:
105
119
  return state;
106
120
  }
@@ -172,6 +186,8 @@ export function NavigationProvider<TParams = unknown>({
172
186
  openModal: <TParams,>(id: string, params?: TParams) =>
173
187
  dispatch({ type: "openModal", modal: { id, params } }),
174
188
  closeModal: () => dispatch({ type: "closeModal" }),
189
+ updateModal: <TParams,>(params: TParams) =>
190
+ dispatch({ type: "updateModal", params }),
175
191
  hasModal: modalStack.length > 0,
176
192
  goBack,
177
193
  setBackHandler,
@@ -0,0 +1,100 @@
1
+ import type { AnyCommand } from "../../core/command.ts";
2
+ import type { NavigationAPI } from "../context/NavigationContext.tsx";
3
+
4
+ import { RenderCommandBrowserScreen } from "../semantic/render.tsx";
5
+ import { schemaToFieldConfigs } from "../utils/schemaToFields.ts";
6
+
7
+ import type { CommandBrowserRouteParams, TuiRoute } from "../driver/types.ts";
8
+ import type { ConfigController } from "./ConfigController.tsx";
9
+
10
+ export class CommandBrowserController {
11
+ private commands: AnyCommand[];
12
+ private configController: ConfigController;
13
+ private navigation: NavigationAPI;
14
+
15
+ private clampSelectedIndex(index: number, commands: AnyCommand[]): number {
16
+ return Math.max(0, Math.min(index, Math.max(0, commands.length - 1)));
17
+ }
18
+
19
+ public constructor({
20
+ commands,
21
+ navigation,
22
+ configController,
23
+ }: {
24
+ commands: AnyCommand[];
25
+ navigation: NavigationAPI;
26
+ configController: ConfigController;
27
+ }) {
28
+ this.commands = commands;
29
+ this.navigation = navigation;
30
+ this.configController = configController;
31
+ }
32
+
33
+ public render(): { node: React.ReactNode; breadcrumb: string[] } {
34
+ const params = (this.navigation.current.params ?? { commandPath: [] }) as CommandBrowserRouteParams;
35
+ const commandPath = params.commandPath ?? [];
36
+ const selectedIndex = params.selectedIndex ?? 0;
37
+
38
+ const currentCommands = this.getCommandsAtPath(commandPath);
39
+
40
+ return {
41
+ breadcrumb: commandPath,
42
+ node: (
43
+ <RenderCommandBrowserScreen
44
+ commandId={commandPath}
45
+ commands={currentCommands}
46
+ selectedCommandIndex={this.clampSelectedIndex(selectedIndex, currentCommands)}
47
+ onOpenPath={(nextPath) => {
48
+ this.navigation.replace("commandBrowser" satisfies TuiRoute, { commandPath: nextPath, selectedIndex: 0 });
49
+ }}
50
+ onSelectCommand={(index) => {
51
+ const clampedIndex = this.clampSelectedIndex(index, currentCommands);
52
+ this.navigation.replace("commandBrowser" satisfies TuiRoute, { commandPath, selectedIndex: clampedIndex });
53
+ }}
54
+ onRunSelected={() => {
55
+ const clampedIndex = this.clampSelectedIndex(selectedIndex, currentCommands);
56
+ const selected = currentCommands[clampedIndex];
57
+ if (!selected) {
58
+ return;
59
+ }
60
+
61
+ // If selected command has navigable subcommands, navigate to them instead of config
62
+ const navigableSubCommands = selected.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
63
+ if (navigableSubCommands.length > 0) {
64
+ this.navigation.replace("commandBrowser" satisfies TuiRoute, {
65
+ commandPath: [...commandPath, selected.name],
66
+ selectedIndex: 0
67
+ });
68
+ return;
69
+ }
70
+
71
+ this.navigation.push("config" satisfies TuiRoute, {
72
+ command: selected,
73
+ commandPath,
74
+ values: this.configController.initializeValues(selected),
75
+ fieldConfigs: schemaToFieldConfigs(selected.options),
76
+ });
77
+ }}
78
+ />
79
+ ),
80
+ };
81
+ }
82
+
83
+ private getCommandsAtPath(commandPath: string[]): AnyCommand[] {
84
+ if (commandPath.length === 0) {
85
+ return this.commands.filter((cmd) => cmd.supportsTui());
86
+ }
87
+
88
+ let current: AnyCommand[] = this.commands;
89
+ for (const pathPart of commandPath) {
90
+ const found = current.find((c) => c.name === pathPart);
91
+ if (found?.subCommands) {
92
+ current = found.subCommands.filter((sub) => sub.supportsTui());
93
+ } else {
94
+ break;
95
+ }
96
+ }
97
+
98
+ return current;
99
+ }
100
+ }
@@ -0,0 +1,183 @@
1
+ import type { AnyCommand } from "../../core/command.ts";
2
+ import type { NavigationAPI } from "../context/NavigationContext.tsx";
3
+ import type { ExecutorContextValue } from "../context/ExecutorContext.tsx";
4
+
5
+ import { RenderConfigScreen } from "../semantic/render.tsx";
6
+
7
+ import { buildCliCommand } from "../utils/buildCliCommand.ts";
8
+ import { loadPersistedParameters, savePersistedParameters } from "../utils/parameterPersistence.ts";
9
+
10
+ import type { OptionDef, OptionSchema } from "../../types/command.ts";
11
+ import type {
12
+ ConfigRouteParams,
13
+ EditorModalParams,
14
+ TuiRoute,
15
+ } from "../driver/types.ts";
16
+
17
+ export class ConfigController {
18
+ private appName: string;
19
+ private navigation: NavigationAPI;
20
+ private executor: ExecutorContextValue;
21
+
22
+ public constructor({
23
+ appName,
24
+ navigation,
25
+ executor,
26
+ }: {
27
+ appName: string;
28
+ navigation: NavigationAPI;
29
+ executor: ExecutorContextValue;
30
+ }) {
31
+ this.appName = appName;
32
+ this.navigation = navigation;
33
+ this.executor = executor;
34
+ }
35
+
36
+ public async run(params: ConfigRouteParams): Promise<void> {
37
+ savePersistedParameters(this.appName, params.command.name, params.values);
38
+
39
+ this.navigation.push("running" satisfies TuiRoute, {
40
+ command: params.command,
41
+ commandPath: params.commandPath,
42
+ values: params.values,
43
+ });
44
+
45
+ // Set a back handler that cancels the execution when Esc is pressed
46
+ this.navigation.setBackHandler(() => {
47
+ this.executor.cancel();
48
+ return true; // We handle the back action - don't pop the stack yet
49
+ });
50
+
51
+ try {
52
+ const outcome = await this.executor.execute(params.command, params.values);
53
+
54
+ if (outcome.cancelled) {
55
+ this.navigation.pop();
56
+ return;
57
+ }
58
+
59
+ if (outcome.success) {
60
+ this.navigation.replace("results" satisfies TuiRoute, {
61
+ command: params.command,
62
+ commandPath: params.commandPath,
63
+ values: params.values,
64
+ result: outcome.result ?? null,
65
+ });
66
+ return;
67
+ }
68
+
69
+ this.navigation.replace("error" satisfies TuiRoute, {
70
+ command: params.command,
71
+ commandPath: params.commandPath,
72
+ values: params.values,
73
+ error: outcome.error ?? new Error("Unknown error"),
74
+ });
75
+ } finally {
76
+ // Clear the back handler when execution completes
77
+ this.navigation.setBackHandler(null);
78
+ }
79
+ }
80
+
81
+ public getCopyPayload(params: {
82
+ command: AnyCommand;
83
+ commandPath: string[];
84
+ values: Record<string, unknown>;
85
+ }): { label: string; content: string } {
86
+ const schema = params.command.options as OptionSchema;
87
+ const cli = buildCliCommand(this.appName, params.commandPath, schema, params.values as any);
88
+ return { label: "CLI", content: cli };
89
+ }
90
+
91
+ public render(): { node: React.ReactNode; breadcrumb?: string[] } {
92
+ const params = this.navigation.current.params as ConfigRouteParams | undefined;
93
+
94
+ if (!params) {
95
+ return { node: null };
96
+ }
97
+
98
+ const title = `Configure: ${params.command.displayName ?? params.command.name}`;
99
+ const selectedFieldIndex = params.selectedFieldIndex ?? 0;
100
+ const clampedIndex = Math.min(selectedFieldIndex, Math.max(0, params.fieldConfigs.length));
101
+
102
+ const schema = params.command.options as OptionSchema;
103
+ const cliCommand = buildCliCommand(this.appName, params.commandPath, schema, params.values as any);
104
+
105
+ return {
106
+ breadcrumb: params.commandPath,
107
+ node: (
108
+ <RenderConfigScreen
109
+ title={title}
110
+ commandId={params.commandPath}
111
+ fieldConfigs={params.fieldConfigs}
112
+ values={params.values}
113
+ cliCommand={cliCommand}
114
+ selectedFieldIndex={clampedIndex}
115
+ onSelectionChange={(index) => {
116
+ const maxIndex = params.fieldConfigs.length;
117
+ const nextIndex = Math.max(0, Math.min(index, maxIndex));
118
+ this.navigation.replace("config" satisfies TuiRoute, {
119
+ ...params,
120
+ selectedFieldIndex: nextIndex,
121
+ });
122
+ }}
123
+ onEditField={(fieldId) => {
124
+ const fieldValue = params.values[fieldId];
125
+ const fieldConfig = params.fieldConfigs.find((f) => f.key === fieldId);
126
+ const fieldDisplayName = fieldConfig?.label ?? fieldId;
127
+
128
+ this.navigation.openModal<EditorModalParams>("editor", {
129
+ fieldKey: fieldId,
130
+ fieldDisplayName,
131
+ currentValue: fieldValue,
132
+ fieldConfigs: params.fieldConfigs,
133
+
134
+ onSubmit: (value: unknown) => {
135
+ this.navigation.replace("config" satisfies TuiRoute, {
136
+ ...params,
137
+ values: { ...params.values, [fieldId]: value },
138
+ });
139
+ this.navigation.closeModal();
140
+ },
141
+ onCancel: () => {
142
+ this.navigation.closeModal();
143
+ },
144
+ });
145
+ }}
146
+ onRun={() => {
147
+ void this.run(params);
148
+ }}
149
+ />
150
+ ),
151
+ };
152
+ }
153
+
154
+ public initializeValues(cmd: AnyCommand): Record<string, unknown> {
155
+ const defaults: Record<string, unknown> = {};
156
+ const optionDefs = cmd.options as OptionSchema;
157
+
158
+ for (const [key, def] of Object.entries(optionDefs)) {
159
+ const typedDef = def as OptionDef;
160
+ if (typedDef.default !== undefined) {
161
+ defaults[key] = typedDef.default;
162
+ } else {
163
+ switch (typedDef.type) {
164
+ case "string":
165
+ defaults[key] = typedDef.enum?.[0] ?? "";
166
+ break;
167
+ case "number":
168
+ defaults[key] = typedDef.min ?? 0;
169
+ break;
170
+ case "boolean":
171
+ defaults[key] = false;
172
+ break;
173
+ case "array":
174
+ defaults[key] = [];
175
+ break;
176
+ }
177
+ }
178
+ }
179
+
180
+ const persisted = loadPersistedParameters(this.appName, cmd.name);
181
+ return { ...defaults, ...persisted };
182
+ }
183
+ }
@@ -0,0 +1,169 @@
1
+ import type { NavigationAPI } from "../context/NavigationContext.tsx";
2
+
3
+ import { RenderEditorScreen } from "../semantic/render.tsx";
4
+
5
+ import type { CopyPayload, EditorModalParams } from "../driver/types.ts";
6
+
7
+ export class EditorController {
8
+ private navigation: NavigationAPI;
9
+
10
+ public constructor({ navigation }: { navigation: NavigationAPI }) {
11
+ this.navigation = navigation;
12
+ }
13
+
14
+ private getSelectIndexForValue(options: { value: string }[], value: string): number {
15
+ const index = options.findIndex((o) => o.value === value);
16
+ return index >= 0 ? index : 0;
17
+ }
18
+
19
+ private updateModalBuffer(params: EditorModalParams, bufferValue: string, selectIndex?: number): void {
20
+ this.navigation.updateModal<EditorModalParams>({
21
+ ...params,
22
+ bufferValue,
23
+ selectIndex,
24
+ });
25
+ }
26
+
27
+ private parseValueByFieldType(type: string, valueString: string, fallback: unknown): unknown {
28
+ if (type === "number") {
29
+ const num = Number(valueString);
30
+ return Number.isFinite(num) ? num : fallback;
31
+ }
32
+
33
+ if (type === "boolean") {
34
+ const normalized = valueString.trim().toLowerCase();
35
+ if (normalized === "true") return true;
36
+ if (normalized === "false") return false;
37
+ return fallback;
38
+ }
39
+
40
+ return valueString;
41
+ }
42
+
43
+ public getCopyPayload(): CopyPayload | null {
44
+ const params = this.navigation.modalStack[this.navigation.modalStack.length - 1]?.params as
45
+ | EditorModalParams
46
+ | undefined;
47
+ if (!params) {
48
+ return null;
49
+ }
50
+
51
+ const activeModalId = this.navigation.modalStack[this.navigation.modalStack.length - 1]?.id;
52
+ if (activeModalId !== "editor") {
53
+ return null;
54
+ }
55
+
56
+ const valueString = params.bufferValue ?? String(params.currentValue ?? "");
57
+
58
+ return {
59
+ label: `Field: ${params.fieldDisplayName}`,
60
+ content: valueString,
61
+ };
62
+ }
63
+
64
+ public render(modalParams: EditorModalParams | undefined): React.ReactNode {
65
+ if (!modalParams) {
66
+ return null;
67
+ }
68
+
69
+ const fieldConfig = modalParams.fieldConfigs.find((f) => f.key === modalParams.fieldKey);
70
+ const bufferString = modalParams.bufferValue;
71
+
72
+ if (fieldConfig?.type === "enum") {
73
+ const options = (fieldConfig.options ?? []).map((o) => ({
74
+ label: String(o.name),
75
+ value: String(o.value),
76
+ }));
77
+
78
+ const currentValueString = bufferString ?? String(modalParams.currentValue ?? "");
79
+ const index = modalParams.selectIndex ?? this.getSelectIndexForValue(options, currentValueString);
80
+
81
+ return (
82
+ <RenderEditorScreen
83
+ key="modal-editor"
84
+ fieldId={modalParams.fieldKey}
85
+ label={modalParams.fieldDisplayName}
86
+ valueString={options[index]?.value ?? currentValueString}
87
+ editorType="select"
88
+ selectOptions={options}
89
+ selectIndex={index}
90
+ onChangeSelectIndex={(nextIndex) => {
91
+ const clamped = Math.max(0, Math.min(nextIndex, Math.max(0, options.length - 1)));
92
+ const next = options[clamped];
93
+ this.updateModalBuffer(modalParams, next ? next.value : "", clamped);
94
+ }}
95
+ onSubmit={() => {
96
+ const valueString = bufferString ?? String(modalParams.currentValue ?? "");
97
+ const match = fieldConfig.options?.find((o) => String(o.value) === valueString);
98
+ modalParams.onSubmit(match?.value ?? valueString);
99
+ }}
100
+ onCancel={() => {
101
+ this.navigation.closeModal();
102
+ }}
103
+ />
104
+ );
105
+ }
106
+
107
+ if (fieldConfig?.type === "boolean") {
108
+ const options = [
109
+ { label: "true", value: "true" },
110
+ { label: "false", value: "false" },
111
+ ];
112
+
113
+ const currentBool =
114
+ bufferString !== undefined ? bufferString.trim().toLowerCase() === "true" : Boolean(modalParams.currentValue);
115
+ const index = modalParams.selectIndex ?? (currentBool ? 0 : 1);
116
+
117
+ return (
118
+ <RenderEditorScreen
119
+ key="modal-editor"
120
+ fieldId={modalParams.fieldKey}
121
+ label={modalParams.fieldDisplayName}
122
+ valueString={options[index]!.value}
123
+ editorType="select"
124
+ selectOptions={options}
125
+ selectIndex={index}
126
+ onChangeSelectIndex={(nextIndex) => {
127
+ const clamped = Math.max(0, Math.min(nextIndex, 1));
128
+ this.updateModalBuffer(modalParams, options[clamped]!.value, clamped);
129
+ }}
130
+ onSubmit={() => {
131
+ const normalized = (bufferString ?? String(Boolean(modalParams.currentValue))).trim().toLowerCase();
132
+ modalParams.onSubmit(normalized === "true");
133
+ }}
134
+ onCancel={() => {
135
+ this.navigation.closeModal();
136
+ }}
137
+ />
138
+ );
139
+ }
140
+
141
+
142
+ return (
143
+ <RenderEditorScreen
144
+ key="modal-editor"
145
+ fieldId={modalParams.fieldKey}
146
+ label={modalParams.fieldDisplayName}
147
+ valueString={bufferString ?? String(modalParams.currentValue ?? "")}
148
+ editorType={fieldConfig?.type === "number" ? "number" : "text"}
149
+ onChangeText={(text) => {
150
+ this.updateModalBuffer(modalParams, text);
151
+ }}
152
+ onSubmit={() => {
153
+ const valueString = bufferString ?? String(modalParams.currentValue ?? "");
154
+
155
+ if (!fieldConfig) {
156
+ modalParams.onSubmit(valueString);
157
+ return;
158
+ }
159
+
160
+ const parsed = this.parseValueByFieldType(fieldConfig.type, valueString, modalParams.currentValue);
161
+ modalParams.onSubmit(parsed);
162
+ }}
163
+ onCancel={() => {
164
+ this.navigation.closeModal();
165
+ }}
166
+ />
167
+ );
168
+ }
169
+ }
@@ -0,0 +1,48 @@
1
+ import { LogLevel, type LogEvent } from "../../core/logger.ts";
2
+ import type { NavigationAPI } from "../context/NavigationContext.tsx";
3
+
4
+ import { RenderLogsScreen } from "../semantic/render.tsx";
5
+
6
+ import type { CopyPayload } from "../driver/types.ts";
7
+
8
+ export class LogsController {
9
+ private navigation: NavigationAPI;
10
+
11
+ public constructor({ navigation }: { navigation: NavigationAPI }) {
12
+ this.navigation = navigation;
13
+ }
14
+
15
+ public getCopyPayload(logs: LogEvent[]): CopyPayload {
16
+ const text = this.formatLogText(logs);
17
+ return { label: "Logs", content: text };
18
+ }
19
+
20
+ public render(logs: LogEvent[]): React.ReactNode {
21
+ const items = this.formatLogItems(logs);
22
+
23
+ return (
24
+ <RenderLogsScreen
25
+ key="modal-logs"
26
+ items={items}
27
+ onClose={() => this.navigation.closeModal()}
28
+ />
29
+ );
30
+ }
31
+
32
+ private formatLogItems(logs: LogEvent[]): { level: string; message: string; timestamp: number }[] {
33
+ return logs.map((l) => ({
34
+ level: LogLevel[l.level],
35
+ message: l.message,
36
+ timestamp: l.timestamp.getTime(),
37
+ }));
38
+ }
39
+
40
+ private formatLogText(logs: LogEvent[]): string {
41
+ return logs
42
+ .map((l) => {
43
+ const timestamp = l.timestamp.toISOString();
44
+ return `[${timestamp}] ${LogLevel[l.level].toUpperCase()}: ${l.message}`;
45
+ })
46
+ .join("\n");
47
+ }
48
+ }
@@ -0,0 +1,110 @@
1
+ import type { CommandResult } from "../../core/command.ts";
2
+ import type { CopyPayload, ErrorRouteParams, ResultsRouteParams, TuiRoute } from "../driver/types.ts";
3
+ import type { NavigationAPI } from "../context/NavigationContext.tsx";
4
+
5
+ import { RenderRunningScreen } from "../semantic/render.tsx";
6
+
7
+ export type OutcomeRoute = Extract<TuiRoute, "running" | "results" | "error">;
8
+
9
+ export class OutcomeController {
10
+ private navigation: NavigationAPI;
11
+
12
+ public constructor({ navigation }: { navigation: NavigationAPI }) {
13
+ this.navigation = navigation;
14
+ }
15
+
16
+ public render(route: OutcomeRoute): { node: React.ReactNode } {
17
+ if (route === "running") {
18
+ return { node: <RenderRunningScreen title="Waiting for results..." kind="running" /> };
19
+ }
20
+
21
+ if (route === "results") {
22
+ const params = this.navigation.current.params as ResultsRouteParams | undefined;
23
+ const result = params?.result as CommandResult | undefined;
24
+ const command = params?.command;
25
+
26
+ // Check if command has a custom result renderer
27
+ let customContent: React.ReactNode = undefined;
28
+ if (result && command?.renderResult) {
29
+ try {
30
+ customContent = command.renderResult(result);
31
+ } catch {
32
+ // If custom renderer fails, fall back to default display
33
+ customContent = undefined;
34
+ }
35
+ }
36
+
37
+ return {
38
+ node: (
39
+ <RenderRunningScreen
40
+ title="Results"
41
+ kind="results"
42
+ message={result?.message}
43
+ result={result}
44
+ customContent={customContent}
45
+ />
46
+ ),
47
+ };
48
+ }
49
+
50
+ const params = this.navigation.current.params as { error: Error } | undefined;
51
+ return {
52
+ node: (
53
+ <RenderRunningScreen
54
+ title="Error"
55
+ kind="error"
56
+ message={String(params?.error?.message ?? "Unknown error")}
57
+ />
58
+ ),
59
+ };
60
+ }
61
+
62
+ public getCopyPayload(route: OutcomeRoute): CopyPayload | null {
63
+ if (route === "results") {
64
+ const params = this.navigation.current.params as ResultsRouteParams | undefined;
65
+ const result = params?.result as CommandResult | undefined;
66
+ const command = params?.command;
67
+
68
+ // Check if command has a custom clipboard content provider
69
+ if (result && command?.getClipboardContent) {
70
+ try {
71
+ const content = command.getClipboardContent(result);
72
+ if (content !== undefined) {
73
+ return { label: "result", content };
74
+ }
75
+ } catch {
76
+ // Fall through to default behavior
77
+ }
78
+ }
79
+
80
+ if (result !== undefined) {
81
+ // If result has data, stringify it for clipboard
82
+ if (result.data !== undefined) {
83
+ return {
84
+ label: "result",
85
+ content: typeof result.data === "object"
86
+ ? JSON.stringify(result.data, null, 2)
87
+ : String(result.data),
88
+ };
89
+ }
90
+ // Otherwise use the message
91
+ return {
92
+ label: "result",
93
+ content: result.message ?? "",
94
+ };
95
+ }
96
+ }
97
+
98
+ if (route === "error") {
99
+ const params = this.navigation.current.params as ErrorRouteParams | undefined;
100
+ if (params?.error) {
101
+ return {
102
+ label: "error",
103
+ content: params.error.message ?? String(params.error),
104
+ };
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
110
+ }