@pablozaiden/terminatui 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -0
- package/examples/tui-app/commands/config/app/get.ts +66 -0
- package/examples/tui-app/commands/config/app/index.ts +27 -0
- package/examples/tui-app/commands/config/app/set.ts +86 -0
- package/examples/tui-app/commands/config/index.ts +32 -0
- package/examples/tui-app/commands/config/user/get.ts +65 -0
- package/examples/tui-app/commands/config/user/index.ts +27 -0
- package/examples/tui-app/commands/config/user/set.ts +61 -0
- package/examples/tui-app/commands/greet.ts +1 -0
- package/examples/tui-app/commands/index.ts +1 -0
- package/examples/tui-app/commands/math.ts +1 -0
- package/examples/tui-app/commands/status.ts +3 -1
- package/examples/tui-app/index.ts +2 -1
- package/guides/07-async-cancellation.md +1 -53
- package/guides/08-complete-application.md +1 -137
- package/package.json +1 -1
- package/src/builtins/help.ts +7 -0
- package/src/tui/TuiApp.tsx +51 -14
- package/src/tui/components/CommandSelector.tsx +8 -1
package/AGENTS.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Default to using Bun instead of Node.js.
|
|
2
|
+
|
|
3
|
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
|
4
|
+
- Use `bun test` instead of `jest` or `vitest`
|
|
5
|
+
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
|
6
|
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
7
|
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
8
|
+
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
|
9
|
+
- Bun automatically loads .env, so don't use dotenv.
|
|
10
|
+
|
|
11
|
+
## APIs
|
|
12
|
+
|
|
13
|
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
14
|
+
- Bun.$`ls` instead of execa.
|
|
15
|
+
|
|
16
|
+
## Testing
|
|
17
|
+
|
|
18
|
+
Always run `bun run build` before running tests, to make sure there are no build errors.
|
|
19
|
+
Use `bun run test` to run all the tests.
|
|
20
|
+
|
|
21
|
+
Always run `bun run test` when you think you are done making changes.
|
|
22
|
+
|
|
23
|
+
```ts#index.test.ts
|
|
24
|
+
import { test, expect } from "bun:test";
|
|
25
|
+
|
|
26
|
+
test("hello world", () => {
|
|
27
|
+
expect(1).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type OptionSchema,
|
|
5
|
+
type OptionValues,
|
|
6
|
+
type CommandResult
|
|
7
|
+
} from "../../../../../src/index.ts";
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
key: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Application configuration key to get",
|
|
13
|
+
required: true,
|
|
14
|
+
label: "Key",
|
|
15
|
+
order: 1,
|
|
16
|
+
group: "Required",
|
|
17
|
+
placeholder: "e.g., port, debug, logLevel",
|
|
18
|
+
},
|
|
19
|
+
} as const satisfies OptionSchema;
|
|
20
|
+
|
|
21
|
+
export class AppGetCommand extends Command<typeof options> {
|
|
22
|
+
readonly name = "get";
|
|
23
|
+
override displayName = "Get App Config";
|
|
24
|
+
readonly description = "Get an application configuration value";
|
|
25
|
+
readonly options = options;
|
|
26
|
+
|
|
27
|
+
override readonly actionLabel = "Get Value";
|
|
28
|
+
|
|
29
|
+
override readonly examples = [
|
|
30
|
+
{ command: "config app get --key port", description: "Get app port" },
|
|
31
|
+
{ command: "config app get --key logLevel", description: "Get log level" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
override async execute(ctx: AppContext, opts: OptionValues<typeof options>): Promise<CommandResult> {
|
|
35
|
+
// Simulated app config store
|
|
36
|
+
const appConfig: Record<string, string | number | boolean> = {
|
|
37
|
+
port: 3000,
|
|
38
|
+
debug: true,
|
|
39
|
+
logLevel: "info",
|
|
40
|
+
maxConnections: 100,
|
|
41
|
+
timeout: 30000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const value = appConfig[opts.key];
|
|
45
|
+
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
ctx.logger.warn(`Key "${opts.key}" not found in application configuration`);
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
message: `Key "${opts.key}" not found`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ctx.logger.info(`Retrieved app.${opts.key} = ${value}`);
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
data: { key: opts.key, value },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override renderResult(result: CommandResult): string {
|
|
62
|
+
if (!result.success) return result.message || "Error";
|
|
63
|
+
const data = result.data as { key: string; value: string | number | boolean };
|
|
64
|
+
return `app.${data.key} = ${JSON.stringify(data.value)}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type CommandResult
|
|
5
|
+
} from "../../../../../src/index.ts";
|
|
6
|
+
import { AppGetCommand } from "./get.ts";
|
|
7
|
+
import { AppSetCommand } from "./set.ts";
|
|
8
|
+
|
|
9
|
+
export class AppConfigCommand extends Command {
|
|
10
|
+
readonly name = "app";
|
|
11
|
+
override displayName = "App Settings";
|
|
12
|
+
readonly description = "Manage application configuration";
|
|
13
|
+
readonly options = {};
|
|
14
|
+
|
|
15
|
+
override readonly subCommands = [
|
|
16
|
+
new AppGetCommand(),
|
|
17
|
+
new AppSetCommand(),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
override execute(_ctx: AppContext): CommandResult {
|
|
21
|
+
console.log("Use 'config app <command>' for application configuration.");
|
|
22
|
+
console.log("Available: get, set");
|
|
23
|
+
return { success: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { AppGetCommand, AppSetCommand };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type OptionSchema,
|
|
5
|
+
type OptionValues,
|
|
6
|
+
type CommandResult
|
|
7
|
+
} from "../../../../../src/index.ts";
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
key: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Application configuration key to set",
|
|
13
|
+
required: true,
|
|
14
|
+
label: "Key",
|
|
15
|
+
order: 1,
|
|
16
|
+
group: "Required",
|
|
17
|
+
placeholder: "e.g., port, debug, logLevel",
|
|
18
|
+
},
|
|
19
|
+
value: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Value to set",
|
|
22
|
+
required: true,
|
|
23
|
+
label: "Value",
|
|
24
|
+
order: 2,
|
|
25
|
+
group: "Required",
|
|
26
|
+
placeholder: "Enter value...",
|
|
27
|
+
},
|
|
28
|
+
type: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Value type for parsing",
|
|
31
|
+
enum: ["string", "number", "boolean"] as const,
|
|
32
|
+
default: "string",
|
|
33
|
+
label: "Value Type",
|
|
34
|
+
order: 3,
|
|
35
|
+
group: "Options",
|
|
36
|
+
},
|
|
37
|
+
} as const satisfies OptionSchema;
|
|
38
|
+
|
|
39
|
+
export class AppSetCommand extends Command<typeof options> {
|
|
40
|
+
readonly name = "set";
|
|
41
|
+
override displayName = "Set App Config";
|
|
42
|
+
readonly description = "Set an application configuration value";
|
|
43
|
+
readonly options = options;
|
|
44
|
+
|
|
45
|
+
override readonly actionLabel = "Set Value";
|
|
46
|
+
|
|
47
|
+
override readonly examples = [
|
|
48
|
+
{ command: "config app set --key port --value 8080 --type number", description: "Set app port" },
|
|
49
|
+
{ command: "config app set --key debug --value false --type boolean", description: "Disable debug" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
override async execute(ctx: AppContext, opts: OptionValues<typeof options>): Promise<CommandResult> {
|
|
53
|
+
let parsedValue: string | number | boolean = opts.value;
|
|
54
|
+
|
|
55
|
+
// Parse value based on type
|
|
56
|
+
if (opts.type === "number") {
|
|
57
|
+
parsedValue = Number(opts.value);
|
|
58
|
+
if (isNaN(parsedValue)) {
|
|
59
|
+
ctx.logger.error(`Invalid number value: "${opts.value}"`);
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
message: `Invalid number: "${opts.value}"`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
} else if (opts.type === "boolean") {
|
|
66
|
+
parsedValue = opts.value.toLowerCase() === "true";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ctx.logger.info(`Setting app.${opts.key} = ${JSON.stringify(parsedValue)}`);
|
|
70
|
+
|
|
71
|
+
// Simulate setting the value
|
|
72
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
73
|
+
|
|
74
|
+
ctx.logger.info(`Successfully updated application configuration`);
|
|
75
|
+
return {
|
|
76
|
+
success: true,
|
|
77
|
+
data: { key: opts.key, value: parsedValue, type: opts.type },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override renderResult(result: CommandResult): string {
|
|
82
|
+
if (!result.success) return result.message || "Error";
|
|
83
|
+
const data = result.data as { key: string; value: string | number | boolean; type: string };
|
|
84
|
+
return `✓ Set app.${data.key} = ${JSON.stringify(data.value)} (${data.type})`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type CommandResult
|
|
5
|
+
} from "../../../../src/index.ts";
|
|
6
|
+
import { UserConfigCommand } from "./user/index.ts";
|
|
7
|
+
import { AppConfigCommand } from "./app/index.ts";
|
|
8
|
+
|
|
9
|
+
export class ConfigCommand extends Command {
|
|
10
|
+
readonly name = "config";
|
|
11
|
+
override displayName = "Config";
|
|
12
|
+
readonly description = "Manage configuration (user and app settings)";
|
|
13
|
+
readonly options = {};
|
|
14
|
+
|
|
15
|
+
override readonly subCommands = [
|
|
16
|
+
new UserConfigCommand(),
|
|
17
|
+
new AppConfigCommand(),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
override readonly examples = [
|
|
21
|
+
{ command: "config user get --key name", description: "Get user name" },
|
|
22
|
+
{ command: "config user set --key theme --value dark", description: "Set user theme" },
|
|
23
|
+
{ command: "config app get --key port", description: "Get app port" },
|
|
24
|
+
{ command: "config app set --key debug --value true --type boolean", description: "Enable debug mode" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
override execute(_ctx: AppContext): CommandResult {
|
|
28
|
+
console.log("Use 'config <category> <command>' to manage settings.");
|
|
29
|
+
console.log("Categories: user, app");
|
|
30
|
+
return { success: true };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type OptionSchema,
|
|
5
|
+
type OptionValues,
|
|
6
|
+
type CommandResult
|
|
7
|
+
} from "../../../../../src/index.ts";
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
key: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Configuration key to get",
|
|
13
|
+
required: true,
|
|
14
|
+
label: "Key",
|
|
15
|
+
order: 1,
|
|
16
|
+
group: "Required",
|
|
17
|
+
placeholder: "e.g., name, email, theme",
|
|
18
|
+
},
|
|
19
|
+
} as const satisfies OptionSchema;
|
|
20
|
+
|
|
21
|
+
export class UserGetCommand extends Command<typeof options> {
|
|
22
|
+
readonly name = "get";
|
|
23
|
+
override displayName = "Get User Config";
|
|
24
|
+
readonly description = "Get a user configuration value";
|
|
25
|
+
readonly options = options;
|
|
26
|
+
|
|
27
|
+
override readonly actionLabel = "Get Value";
|
|
28
|
+
|
|
29
|
+
override readonly examples = [
|
|
30
|
+
{ command: "config user get --key name", description: "Get user name" },
|
|
31
|
+
{ command: "config user get --key email", description: "Get user email" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
override async execute(ctx: AppContext, opts: OptionValues<typeof options>): Promise<CommandResult> {
|
|
35
|
+
// Simulated user config store
|
|
36
|
+
const userConfig: Record<string, string> = {
|
|
37
|
+
name: "John Doe",
|
|
38
|
+
email: "john@example.com",
|
|
39
|
+
theme: "dark",
|
|
40
|
+
language: "en",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const value = userConfig[opts.key];
|
|
44
|
+
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
ctx.logger.warn(`Key "${opts.key}" not found in user configuration`);
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
message: `Key "${opts.key}" not found`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ctx.logger.info(`Retrieved user.${opts.key} = ${value}`);
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
data: { key: opts.key, value },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
override renderResult(result: CommandResult): string {
|
|
61
|
+
if (!result.success) return result.message || "Error";
|
|
62
|
+
const data = result.data as { key: string; value: string };
|
|
63
|
+
return `user.${data.key} = "${data.value}"`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type CommandResult
|
|
5
|
+
} from "../../../../../src/index.ts";
|
|
6
|
+
import { UserGetCommand } from "./get.ts";
|
|
7
|
+
import { UserSetCommand } from "./set.ts";
|
|
8
|
+
|
|
9
|
+
export class UserConfigCommand extends Command {
|
|
10
|
+
readonly name = "user";
|
|
11
|
+
override displayName = "User Settings";
|
|
12
|
+
readonly description = "Manage user configuration";
|
|
13
|
+
readonly options = {};
|
|
14
|
+
|
|
15
|
+
override readonly subCommands = [
|
|
16
|
+
new UserGetCommand(),
|
|
17
|
+
new UserSetCommand(),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
override execute(_ctx: AppContext): CommandResult {
|
|
21
|
+
console.log("Use 'config user <command>' for user configuration.");
|
|
22
|
+
console.log("Available: get, set");
|
|
23
|
+
return { success: true };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { UserGetCommand, UserSetCommand };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
type AppContext,
|
|
4
|
+
type OptionSchema,
|
|
5
|
+
type OptionValues,
|
|
6
|
+
type CommandResult
|
|
7
|
+
} from "../../../../../src/index.ts";
|
|
8
|
+
|
|
9
|
+
const options = {
|
|
10
|
+
key: {
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Configuration key to set",
|
|
13
|
+
required: true,
|
|
14
|
+
label: "Key",
|
|
15
|
+
order: 1,
|
|
16
|
+
group: "Required",
|
|
17
|
+
placeholder: "e.g., name, email, theme",
|
|
18
|
+
},
|
|
19
|
+
value: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Value to set",
|
|
22
|
+
required: true,
|
|
23
|
+
label: "Value",
|
|
24
|
+
order: 2,
|
|
25
|
+
group: "Required",
|
|
26
|
+
placeholder: "Enter value...",
|
|
27
|
+
},
|
|
28
|
+
} as const satisfies OptionSchema;
|
|
29
|
+
|
|
30
|
+
export class UserSetCommand extends Command<typeof options> {
|
|
31
|
+
readonly name = "set";
|
|
32
|
+
override displayName = "Set User Config";
|
|
33
|
+
readonly description = "Set a user configuration value";
|
|
34
|
+
readonly options = options;
|
|
35
|
+
|
|
36
|
+
override readonly actionLabel = "Set Value";
|
|
37
|
+
|
|
38
|
+
override readonly examples = [
|
|
39
|
+
{ command: "config user set --key name --value 'Jane Doe'", description: "Set user name" },
|
|
40
|
+
{ command: "config user set --key theme --value light", description: "Set theme" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
override async execute(ctx: AppContext, opts: OptionValues<typeof options>): Promise<CommandResult> {
|
|
44
|
+
ctx.logger.info(`Setting user.${opts.key} = "${opts.value}"`);
|
|
45
|
+
|
|
46
|
+
// Simulate setting the value
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
48
|
+
|
|
49
|
+
ctx.logger.info(`Successfully updated user configuration`);
|
|
50
|
+
return {
|
|
51
|
+
success: true,
|
|
52
|
+
data: { key: opts.key, value: opts.value },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override renderResult(result: CommandResult): string {
|
|
57
|
+
if (!result.success) return result.message || "Error";
|
|
58
|
+
const data = result.data as { key: string; value: string };
|
|
59
|
+
return `✓ Set user.${data.key} = "${data.value}"`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -37,6 +37,7 @@ const greetOptions = {
|
|
|
37
37
|
|
|
38
38
|
export class GreetCommand extends Command<typeof greetOptions> {
|
|
39
39
|
readonly name = "greet";
|
|
40
|
+
override displayName = "Greet";
|
|
40
41
|
readonly description = "Greet someone with a friendly message";
|
|
41
42
|
readonly options = greetOptions;
|
|
42
43
|
|
|
@@ -12,12 +12,14 @@ const statusOptions = {
|
|
|
12
12
|
description: "Show detailed status",
|
|
13
13
|
default: false,
|
|
14
14
|
label: "Detailed",
|
|
15
|
+
alias: "d",
|
|
15
16
|
order: 1,
|
|
16
17
|
},
|
|
17
18
|
} as const satisfies OptionSchema;
|
|
18
19
|
|
|
19
20
|
export class StatusCommand extends Command<typeof statusOptions> {
|
|
20
21
|
readonly name = "status";
|
|
22
|
+
override displayName = "Status";
|
|
21
23
|
readonly description = "Show application status";
|
|
22
24
|
readonly options = statusOptions;
|
|
23
25
|
|
|
@@ -39,7 +41,7 @@ export class StatusCommand extends Command<typeof statusOptions> {
|
|
|
39
41
|
` Uptime: ${data.uptime}`,
|
|
40
42
|
` Memory: ${data.memory}`,
|
|
41
43
|
` Platform: ${data.platform}`,
|
|
42
|
-
`
|
|
44
|
+
` Bun: ${data.version}`,
|
|
43
45
|
].join("\n");
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { TuiApplication } from "../../src/index.ts";
|
|
16
|
-
import { GreetCommand, MathCommand, StatusCommand } from "./commands/index.ts";
|
|
16
|
+
import { GreetCommand, MathCommand, StatusCommand, ConfigCommand } from "./commands/index.ts";
|
|
17
17
|
|
|
18
18
|
class ExampleApp extends TuiApplication {
|
|
19
19
|
constructor() {
|
|
@@ -24,6 +24,7 @@ class ExampleApp extends TuiApplication {
|
|
|
24
24
|
new GreetCommand(),
|
|
25
25
|
new MathCommand(),
|
|
26
26
|
new StatusCommand(),
|
|
27
|
+
new ConfigCommand(),
|
|
27
28
|
],
|
|
28
29
|
enableTui: true,
|
|
29
30
|
});
|
|
@@ -246,58 +246,7 @@ export class DownloadCommand extends Command<typeof options, DownloadConfig> {
|
|
|
246
246
|
}
|
|
247
247
|
```
|
|
248
248
|
|
|
249
|
-
## Step 4:
|
|
250
|
-
|
|
251
|
-
```typescript
|
|
252
|
-
import React from "react";
|
|
253
|
-
import { Text, Box } from "ink";
|
|
254
|
-
|
|
255
|
-
export class DownloadCommand extends Command<typeof options, DownloadConfig> {
|
|
256
|
-
// ... previous code ...
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Custom TUI result display
|
|
260
|
-
*/
|
|
261
|
-
override renderResult(result: CommandResult): React.ReactNode {
|
|
262
|
-
if (!result.success && result.data?.cancelled) {
|
|
263
|
-
return (
|
|
264
|
-
<Box flexDirection="column">
|
|
265
|
-
<Text color="yellow">⚠ Download Cancelled</Text>
|
|
266
|
-
{result.data?.downloadedBytes > 0 && (
|
|
267
|
-
<Text dimColor>
|
|
268
|
-
Partial: {(result.data.downloadedBytes / 1024 / 1024).toFixed(2)} MB
|
|
269
|
-
</Text>
|
|
270
|
-
)}
|
|
271
|
-
</Box>
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (!result.success) {
|
|
276
|
-
return <Text color="red">✗ {result.message}</Text>;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return (
|
|
280
|
-
<Box flexDirection="column">
|
|
281
|
-
<Text color="green">✓ Download Complete</Text>
|
|
282
|
-
<Text>File: {result.data?.file}</Text>
|
|
283
|
-
<Text>Size: {(result.data?.size / 1024 / 1024).toFixed(2)} MB</Text>
|
|
284
|
-
</Box>
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Copy file path to clipboard
|
|
290
|
-
*/
|
|
291
|
-
override getClipboardContent(result: CommandResult): string | undefined {
|
|
292
|
-
if (result.success && result.data?.file) {
|
|
293
|
-
return result.data.file;
|
|
294
|
-
}
|
|
295
|
-
return undefined;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
## Step 5: Create the Application
|
|
249
|
+
## Step 4: Create the Application
|
|
301
250
|
|
|
302
251
|
Create `src/index.ts`:
|
|
303
252
|
|
|
@@ -381,7 +330,6 @@ try {
|
|
|
381
330
|
- Pass signal to `fetch` and other async APIs
|
|
382
331
|
- Clean up resources on cancellation
|
|
383
332
|
- Return meaningful results for cancelled operations
|
|
384
|
-
- Render different UI for cancelled vs success vs error
|
|
385
333
|
|
|
386
334
|
## Next Steps
|
|
387
335
|
|
|
@@ -149,8 +149,6 @@ export class NotificationService {
|
|
|
149
149
|
Create `src/commands/add.ts`:
|
|
150
150
|
|
|
151
151
|
```typescript
|
|
152
|
-
import React from "react";
|
|
153
|
-
import { Text, Box } from "ink";
|
|
154
152
|
import {
|
|
155
153
|
Command,
|
|
156
154
|
ConfigValidationError,
|
|
@@ -231,49 +229,12 @@ export class AddCommand extends Command<typeof options, AddConfig> {
|
|
|
231
229
|
message: `Created task: ${task.title} (${task.id})`,
|
|
232
230
|
};
|
|
233
231
|
}
|
|
234
|
-
|
|
235
|
-
override renderResult(result: CommandResult): React.ReactNode {
|
|
236
|
-
if (!result.success) {
|
|
237
|
-
return <Text color="red">✗ {result.message}</Text>;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const task = result.data as Task;
|
|
241
|
-
const priorityColor = {
|
|
242
|
-
high: "red",
|
|
243
|
-
medium: "yellow",
|
|
244
|
-
low: "green",
|
|
245
|
-
}[task.priority] as "red" | "yellow" | "green";
|
|
246
|
-
|
|
247
|
-
return (
|
|
248
|
-
<Box flexDirection="column" gap={1}>
|
|
249
|
-
<Text color="green">✓ Task Created</Text>
|
|
250
|
-
<Box>
|
|
251
|
-
<Text dimColor>ID: </Text>
|
|
252
|
-
<Text>{task.id}</Text>
|
|
253
|
-
</Box>
|
|
254
|
-
<Box>
|
|
255
|
-
<Text dimColor>Title: </Text>
|
|
256
|
-
<Text>{task.title}</Text>
|
|
257
|
-
</Box>
|
|
258
|
-
<Box>
|
|
259
|
-
<Text dimColor>Priority: </Text>
|
|
260
|
-
<Text color={priorityColor}>{task.priority}</Text>
|
|
261
|
-
</Box>
|
|
262
|
-
</Box>
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
override getClipboardContent(result: CommandResult): string | undefined {
|
|
267
|
-
return result.data?.id;
|
|
268
|
-
}
|
|
269
232
|
}
|
|
270
233
|
```
|
|
271
234
|
|
|
272
235
|
Create `src/commands/list.ts`:
|
|
273
236
|
|
|
274
237
|
```typescript
|
|
275
|
-
import React from "react";
|
|
276
|
-
import { Text, Box } from "ink";
|
|
277
238
|
import {
|
|
278
239
|
Command,
|
|
279
240
|
type AppContext,
|
|
@@ -344,45 +305,12 @@ export class ListCommand extends Command<typeof options, ListConfig> {
|
|
|
344
305
|
message: `Found ${tasks.length} tasks`,
|
|
345
306
|
};
|
|
346
307
|
}
|
|
347
|
-
|
|
348
|
-
override renderResult(result: CommandResult): React.ReactNode {
|
|
349
|
-
const tasks = (result.data as Task[]) ?? [];
|
|
350
|
-
|
|
351
|
-
if (tasks.length === 0) {
|
|
352
|
-
return <Text dimColor>No tasks found</Text>;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const priorityColor = (p: string) =>
|
|
356
|
-
({ high: "red", medium: "yellow", low: "green" })[p] as "red" | "yellow" | "green";
|
|
357
|
-
|
|
358
|
-
return (
|
|
359
|
-
<Box flexDirection="column" gap={1}>
|
|
360
|
-
<Text bold>Tasks ({tasks.length})</Text>
|
|
361
|
-
{tasks.map((task) => (
|
|
362
|
-
<Box key={task.id} gap={2}>
|
|
363
|
-
<Text dimColor>[{task.id}]</Text>
|
|
364
|
-
<Text color={task.status === "completed" ? "gray" : undefined}>
|
|
365
|
-
{task.status === "completed" ? "✓" : "○"} {task.title}
|
|
366
|
-
</Text>
|
|
367
|
-
<Text color={priorityColor(task.priority)}>{task.priority}</Text>
|
|
368
|
-
</Box>
|
|
369
|
-
))}
|
|
370
|
-
</Box>
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
override getClipboardContent(result: CommandResult): string | undefined {
|
|
375
|
-
const tasks = result.data as Task[];
|
|
376
|
-
return tasks?.map((t) => `${t.id}: ${t.title}`).join("\n");
|
|
377
|
-
}
|
|
378
308
|
}
|
|
379
309
|
```
|
|
380
310
|
|
|
381
311
|
Create `src/commands/complete.ts`:
|
|
382
312
|
|
|
383
313
|
```typescript
|
|
384
|
-
import React from "react";
|
|
385
|
-
import { Text, Box } from "ink";
|
|
386
314
|
import {
|
|
387
315
|
Command,
|
|
388
316
|
ConfigValidationError,
|
|
@@ -451,31 +379,12 @@ export class CompleteCommand extends Command<typeof options, CompleteConfig> {
|
|
|
451
379
|
message: `Completed: ${task.title}`,
|
|
452
380
|
};
|
|
453
381
|
}
|
|
454
|
-
|
|
455
|
-
override renderResult(result: CommandResult): React.ReactNode {
|
|
456
|
-
if (!result.success) {
|
|
457
|
-
return <Text color="red">✗ {result.message}</Text>;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const task = result.data as Task;
|
|
461
|
-
return (
|
|
462
|
-
<Box flexDirection="column">
|
|
463
|
-
<Text color="green">✓ Task Completed</Text>
|
|
464
|
-
<Text>{task.title}</Text>
|
|
465
|
-
<Text dimColor>
|
|
466
|
-
Completed at: {task.completedAt?.toLocaleString()}
|
|
467
|
-
</Text>
|
|
468
|
-
</Box>
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
382
|
}
|
|
472
383
|
```
|
|
473
384
|
|
|
474
385
|
Create `src/commands/stats.ts`:
|
|
475
386
|
|
|
476
387
|
```typescript
|
|
477
|
-
import React from "react";
|
|
478
|
-
import { Text, Box } from "ink";
|
|
479
388
|
import {
|
|
480
389
|
Command,
|
|
481
390
|
type AppContext,
|
|
@@ -510,50 +419,6 @@ export class StatsCommand extends Command<typeof options> {
|
|
|
510
419
|
message: `Total: ${stats.total}, Pending: ${stats.pending}, Completed: ${stats.completed}`,
|
|
511
420
|
};
|
|
512
421
|
}
|
|
513
|
-
|
|
514
|
-
override renderResult(result: CommandResult): React.ReactNode {
|
|
515
|
-
const stats = result.data as TaskStats;
|
|
516
|
-
|
|
517
|
-
const completionRate = stats.total > 0
|
|
518
|
-
? Math.round((stats.completed / stats.total) * 100)
|
|
519
|
-
: 0;
|
|
520
|
-
|
|
521
|
-
return (
|
|
522
|
-
<Box flexDirection="column" gap={1}>
|
|
523
|
-
<Text bold>📊 Task Statistics</Text>
|
|
524
|
-
|
|
525
|
-
<Box flexDirection="column">
|
|
526
|
-
<Box gap={2}>
|
|
527
|
-
<Text dimColor>Total:</Text>
|
|
528
|
-
<Text>{stats.total}</Text>
|
|
529
|
-
</Box>
|
|
530
|
-
<Box gap={2}>
|
|
531
|
-
<Text dimColor>Pending:</Text>
|
|
532
|
-
<Text color="yellow">{stats.pending}</Text>
|
|
533
|
-
</Box>
|
|
534
|
-
<Box gap={2}>
|
|
535
|
-
<Text dimColor>Completed:</Text>
|
|
536
|
-
<Text color="green">{stats.completed}</Text>
|
|
537
|
-
</Box>
|
|
538
|
-
<Box gap={2}>
|
|
539
|
-
<Text dimColor>Completion Rate:</Text>
|
|
540
|
-
<Text color={completionRate >= 50 ? "green" : "yellow"}>
|
|
541
|
-
{completionRate}%
|
|
542
|
-
</Text>
|
|
543
|
-
</Box>
|
|
544
|
-
</Box>
|
|
545
|
-
|
|
546
|
-
<Box flexDirection="column" marginTop={1}>
|
|
547
|
-
<Text bold>By Priority:</Text>
|
|
548
|
-
<Box gap={2}>
|
|
549
|
-
<Text color="red">High: {stats.byPriority.high}</Text>
|
|
550
|
-
<Text color="yellow">Medium: {stats.byPriority.medium}</Text>
|
|
551
|
-
<Text color="green">Low: {stats.byPriority.low}</Text>
|
|
552
|
-
</Box>
|
|
553
|
-
</Box>
|
|
554
|
-
</Box>
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
422
|
}
|
|
558
423
|
```
|
|
559
424
|
|
|
@@ -647,7 +512,6 @@ bun start --verbose add "Debug task" --priority low
|
|
|
647
512
|
| Display Names | `displayName = "Add Task"` |
|
|
648
513
|
| Action Labels | `actionLabel = "Create Task"` |
|
|
649
514
|
| Immediate Execution | `immediateExecution = true` |
|
|
650
|
-
| Custom Results | `renderResult()` |
|
|
651
515
|
| Clipboard Support | `getClipboardContent()` |
|
|
652
516
|
|
|
653
517
|
## What You Learned
|
|
@@ -655,7 +519,7 @@ bun start --verbose add "Debug task" --priority low
|
|
|
655
519
|
- **Project Structure**: Organize code into commands, services, types
|
|
656
520
|
- **Shared Services**: Database and notification services
|
|
657
521
|
- **Command Groups**: Organize commands in TUI sidebar
|
|
658
|
-
- **Full TUI Integration**:
|
|
522
|
+
- **Full TUI Integration**: Clipboard, immediate execution
|
|
659
523
|
- **Lifecycle Hooks**: `onBeforeRun` and `onAfterRun`
|
|
660
524
|
- **Production Patterns**: Error handling, validation, logging
|
|
661
525
|
|
package/package.json
CHANGED
package/src/builtins/help.ts
CHANGED
|
@@ -33,6 +33,13 @@ export class HelpCommand extends Command<OptionSchema> {
|
|
|
33
33
|
this.appVersion = config.appVersion;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Help command is CLI-only (auto-injected for CLI use, not shown in TUI).
|
|
38
|
+
*/
|
|
39
|
+
override supportsTui(): boolean {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
override async execute(_ctx: AppContext): Promise<void> {
|
|
37
44
|
let helpText: string;
|
|
38
45
|
|
package/src/tui/TuiApp.tsx
CHANGED
|
@@ -91,6 +91,7 @@ function TuiAppContent({
|
|
|
91
91
|
const [selectedCommand, setSelectedCommand] = useState<AnyCommand | null>(null);
|
|
92
92
|
const [commandPath, setCommandPath] = useState<string[]>([]);
|
|
93
93
|
const [commandSelectorIndex, setCommandSelectorIndex] = useState(0);
|
|
94
|
+
const [selectorIndexStack, setSelectorIndexStack] = useState<number[]>([]);
|
|
94
95
|
const [selectedFieldIndex, setSelectedFieldIndex] = useState(0);
|
|
95
96
|
const [editingField, setEditingField] = useState<string | null>(null);
|
|
96
97
|
const [focusedSection, setFocusedSection] = useState<FocusedSection>(FocusedSection.Config);
|
|
@@ -133,9 +134,27 @@ function TuiAppContent({
|
|
|
133
134
|
return buildCliCommand(name, commandPath, selectedCommand.options, configValues as OptionValues<OptionSchema>);
|
|
134
135
|
}, [name, commandPath, selectedCommand, configValues]);
|
|
135
136
|
|
|
137
|
+
// Build breadcrumb with display names by traversing the command path
|
|
136
138
|
const breadcrumb = useMemo(() => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
if (commandPath.length === 0) return undefined;
|
|
140
|
+
|
|
141
|
+
const displayNames: string[] = [];
|
|
142
|
+
let current: AnyCommand[] = commands;
|
|
143
|
+
|
|
144
|
+
for (const pathPart of commandPath) {
|
|
145
|
+
const found = current.find((c) => c.name === pathPart);
|
|
146
|
+
if (found) {
|
|
147
|
+
displayNames.push(found.displayName ?? found.name);
|
|
148
|
+
if (found.subCommands) {
|
|
149
|
+
current = found.subCommands;
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
displayNames.push(pathPart);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return displayNames;
|
|
157
|
+
}, [commandPath, commands]);
|
|
139
158
|
|
|
140
159
|
// Initialize config values when command changes
|
|
141
160
|
const initializeConfigValues = useCallback((cmd: AnyCommand) => {
|
|
@@ -178,10 +197,22 @@ function TuiAppContent({
|
|
|
178
197
|
setConfigValues(merged);
|
|
179
198
|
}, [customFields, name]);
|
|
180
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Check if a command has navigable subcommands (excluding commands that don't support TUI).
|
|
202
|
+
*/
|
|
203
|
+
const hasNavigableSubCommands = useCallback((cmd: AnyCommand): boolean => {
|
|
204
|
+
if (!cmd.subCommands || cmd.subCommands.length === 0) return false;
|
|
205
|
+
// Filter out commands that don't support TUI
|
|
206
|
+
const navigable = cmd.subCommands.filter((sub) => sub.supportsTui());
|
|
207
|
+
return navigable.length > 0;
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
181
210
|
// Handlers
|
|
182
211
|
const handleCommandSelect = useCallback((cmd: AnyCommand) => {
|
|
183
|
-
// Check if command has subcommands
|
|
184
|
-
if (
|
|
212
|
+
// Check if command has navigable subcommands (container commands)
|
|
213
|
+
if (hasNavigableSubCommands(cmd)) {
|
|
214
|
+
// Push current selection index to stack before navigating
|
|
215
|
+
setSelectorIndexStack((prev) => [...prev, commandSelectorIndex]);
|
|
185
216
|
// Navigate into subcommands
|
|
186
217
|
setCommandPath((prev) => [...prev, cmd.name]);
|
|
187
218
|
setCommandSelectorIndex(0);
|
|
@@ -201,7 +232,7 @@ function TuiAppContent({
|
|
|
201
232
|
} else {
|
|
202
233
|
setMode(Mode.Config);
|
|
203
234
|
}
|
|
204
|
-
}, [initializeConfigValues]);
|
|
235
|
+
}, [initializeConfigValues, hasNavigableSubCommands, commandSelectorIndex]);
|
|
205
236
|
|
|
206
237
|
const handleBack = useCallback(() => {
|
|
207
238
|
if (mode === Mode.Running) {
|
|
@@ -242,12 +273,15 @@ function TuiAppContent({
|
|
|
242
273
|
}
|
|
243
274
|
resetExecutor();
|
|
244
275
|
} else if (mode === Mode.CommandSelect && commandPath.length > 0) {
|
|
276
|
+
// Pop from selector index stack to restore previous selection
|
|
277
|
+
const previousIndex = selectorIndexStack[selectorIndexStack.length - 1] ?? 0;
|
|
278
|
+
setSelectorIndexStack((prev) => prev.slice(0, -1));
|
|
279
|
+
setCommandSelectorIndex(previousIndex);
|
|
245
280
|
setCommandPath((prev) => prev.slice(0, -1));
|
|
246
|
-
setCommandSelectorIndex(0);
|
|
247
281
|
} else {
|
|
248
282
|
onExit();
|
|
249
283
|
}
|
|
250
|
-
}, [mode, commandPath, selectedCommand, cancel, onExit, resetExecutor]);
|
|
284
|
+
}, [mode, commandPath, selectedCommand, selectorIndexStack, cancel, onExit, resetExecutor]);
|
|
251
285
|
|
|
252
286
|
const handleRunCommand = useCallback(async (cmd?: AnyCommand) => {
|
|
253
287
|
const cmdToRun = cmd ?? selectedCommand;
|
|
@@ -413,18 +447,21 @@ function TuiAppContent({
|
|
|
413
447
|
{ enabled: !editingField && !cliModalVisible }
|
|
414
448
|
);
|
|
415
449
|
|
|
416
|
-
// Get current commands for selector
|
|
450
|
+
// Get current commands for selector (excluding commands that don't support TUI)
|
|
417
451
|
const currentCommands = useMemo(() => {
|
|
418
452
|
if (commandPath.length === 0) {
|
|
419
|
-
return commands;
|
|
453
|
+
return commands.filter((cmd) => cmd.supportsTui());
|
|
420
454
|
}
|
|
421
455
|
|
|
422
|
-
// Navigate to current
|
|
456
|
+
// Navigate through the full path to find current level's subcommands
|
|
423
457
|
let current: AnyCommand[] = commands;
|
|
424
|
-
for (const pathPart of commandPath
|
|
458
|
+
for (const pathPart of commandPath) {
|
|
425
459
|
const found = current.find((c) => c.name === pathPart);
|
|
426
460
|
if (found?.subCommands) {
|
|
427
|
-
|
|
461
|
+
// Filter out commands that don't support TUI
|
|
462
|
+
current = found.subCommands.filter((sub) => sub.supportsTui());
|
|
463
|
+
} else {
|
|
464
|
+
break; // Path invalid or command has no subcommands
|
|
428
465
|
}
|
|
429
466
|
}
|
|
430
467
|
return current;
|
|
@@ -477,7 +514,7 @@ function TuiAppContent({
|
|
|
477
514
|
onSelectionChange={setCommandSelectorIndex}
|
|
478
515
|
onSelect={handleCommandSelect}
|
|
479
516
|
onExit={handleBack}
|
|
480
|
-
breadcrumb={
|
|
517
|
+
breadcrumb={breadcrumb}
|
|
481
518
|
/>
|
|
482
519
|
);
|
|
483
520
|
|
|
@@ -486,7 +523,7 @@ function TuiAppContent({
|
|
|
486
523
|
return (
|
|
487
524
|
<box flexDirection="column" flexGrow={1}>
|
|
488
525
|
<ConfigForm
|
|
489
|
-
title={`Configure: ${selectedCommand.name}`}
|
|
526
|
+
title={`Configure: ${selectedCommand.displayName ?? selectedCommand.name}`}
|
|
490
527
|
fieldConfigs={fieldConfigs}
|
|
491
528
|
values={configValues}
|
|
492
529
|
selectedIndex={selectedFieldIndex}
|
|
@@ -146,9 +146,16 @@ export function CommandSelector({
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
|
-
* Get mode indicator for a command (e.g., "[cli]", "[tui]", "
|
|
149
|
+
* Get mode indicator for a command (e.g., "[cli]", "[tui]", "→" for subcommands).
|
|
150
150
|
*/
|
|
151
151
|
function getModeIndicator(command: Command): string {
|
|
152
|
+
// Show navigation indicator for container commands with navigable subcommands
|
|
153
|
+
// (excluding commands that don't support TUI)
|
|
154
|
+
const navigableSubCommands = command.subCommands?.filter((sub) => sub.supportsTui()) ?? [];
|
|
155
|
+
if (navigableSubCommands.length > 0) {
|
|
156
|
+
return "→";
|
|
157
|
+
}
|
|
158
|
+
|
|
152
159
|
const cli = command.supportsCli();
|
|
153
160
|
const tui = command.supportsTui();
|
|
154
161
|
|