From 69d44ab5bc4832869b54461ed02815a2eedebd81 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 22:42:12 +0100 Subject: [PATCH 1/9] chore(cli): replace the huh form library with an in-repo forms package Generated CLIs built their guided forms (configure, auth login, and missing-input prompts) on charm.land/huh/v2. They now generate internal/forms, a reduced copy of huh v2.0.3 that keeps only what the CLI calls: forms and groups, input, text, select and confirm fields, the base theme, the keymap, and accessible line-prompt mode. File pickers, multi-select, notes, spinners, dynamic *Func fields, extra layouts and themes, and timeouts are dropped. The MIT notice is kept in the package's doc.go. Retained code is unchanged apart from the removed paths, with two deliberate differences: accessible password input reads through golang.org/x/term (already a dependency) instead of charmbracelet/x/term, and program errors are wrapped as "form: ..." instead of "huh: ...". Generated CLIs drop huh, catppuccin, hashstructure, go-humanize and the charmbracelet/x/exp modules. bubbletea and bubbles become direct dependencies whenever interactiveAuth or interactiveMode is enabled. Custom code in the auth login regions that calls huh must switch to the forms package; the setter API is kept so the switch is a rename. --- .changesets/1791409317-72b900b2.yaml | 10 + templates/perms.go | 1529 +++++++++-------- templates/templates/cli/README.md | 8 +- templates/templates/cli/auth.go.stmpl | 20 +- .../internal/forms/accessible.go.stmpl | 182 ++ .../internal/forms/accessor.go.stmpl | 46 + .../cli/auxiliary/internal/forms/doc.go.stmpl | 31 + .../internal/forms/field_confirm.go.stmpl | 308 ++++ .../internal/forms/field_input.go.stmpl | 345 ++++ .../internal/forms/field_select.go.stmpl | 680 ++++++++ .../internal/forms/field_text.go.stmpl | 386 +++++ .../auxiliary/internal/forms/form.go.stmpl | 585 +++++++ .../auxiliary/internal/forms/group.go.stmpl | 410 +++++ .../auxiliary/internal/forms/keymap.go.stmpl | 104 ++ .../auxiliary/internal/forms/option.go.stmpl | 19 + .../internal/forms/selector.go.stmpl | 98 ++ .../auxiliary/internal/forms/theme.go.stmpl | 157 ++ .../auxiliary/internal/forms/wrap.go.stmpl | 9 + .../internal/interactive/interactive.go.stmpl | 70 +- templates/templates/cli/config.ts | 14 +- templates/templates/cli/configure.go.stmpl | 36 +- .../templates/cli/includes/dependencies.ts | 12 +- templates/templates/cli/includes/security.ts | 74 +- .../cli/tests/primary/configure_test.go.stmpl | 4 +- zSDKs/sdk-cli/.speakeasy/gen.lock | 36 +- zSDKs/sdk-cli/go.mod | 6 - zSDKs/sdk-cli/go.sum | 18 - zSDKs/sdk-cli/internal/cli/auth.go | 110 +- zSDKs/sdk-cli/internal/cli/configure.go | 100 +- zSDKs/sdk-cli/internal/forms/accessible.go | 182 ++ zSDKs/sdk-cli/internal/forms/accessor.go | 46 + zSDKs/sdk-cli/internal/forms/doc.go | 31 + zSDKs/sdk-cli/internal/forms/field_confirm.go | 308 ++++ zSDKs/sdk-cli/internal/forms/field_input.go | 345 ++++ zSDKs/sdk-cli/internal/forms/field_select.go | 680 ++++++++ zSDKs/sdk-cli/internal/forms/field_text.go | 386 +++++ zSDKs/sdk-cli/internal/forms/form.go | 585 +++++++ zSDKs/sdk-cli/internal/forms/group.go | 410 +++++ zSDKs/sdk-cli/internal/forms/keymap.go | 104 ++ zSDKs/sdk-cli/internal/forms/option.go | 19 + zSDKs/sdk-cli/internal/forms/selector.go | 98 ++ zSDKs/sdk-cli/internal/forms/theme.go | 157 ++ zSDKs/sdk-cli/internal/forms/wrap.go | 9 + .../internal/interactive/interactive.go | 70 +- 44 files changed, 7770 insertions(+), 1067 deletions(-) create mode 100644 .changesets/1791409317-72b900b2.yaml create mode 100644 templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/form.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/group.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/option.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl create mode 100644 zSDKs/sdk-cli/internal/forms/accessible.go create mode 100644 zSDKs/sdk-cli/internal/forms/accessor.go create mode 100644 zSDKs/sdk-cli/internal/forms/doc.go create mode 100644 zSDKs/sdk-cli/internal/forms/field_confirm.go create mode 100644 zSDKs/sdk-cli/internal/forms/field_input.go create mode 100644 zSDKs/sdk-cli/internal/forms/field_select.go create mode 100644 zSDKs/sdk-cli/internal/forms/field_text.go create mode 100644 zSDKs/sdk-cli/internal/forms/form.go create mode 100644 zSDKs/sdk-cli/internal/forms/group.go create mode 100644 zSDKs/sdk-cli/internal/forms/keymap.go create mode 100644 zSDKs/sdk-cli/internal/forms/option.go create mode 100644 zSDKs/sdk-cli/internal/forms/selector.go create mode 100644 zSDKs/sdk-cli/internal/forms/theme.go create mode 100644 zSDKs/sdk-cli/internal/forms/wrap.go diff --git a/.changesets/1791409317-72b900b2.yaml b/.changesets/1791409317-72b900b2.yaml new file mode 100644 index 000000000..d7e11f70b --- /dev/null +++ b/.changesets/1791409317-72b900b2.yaml @@ -0,0 +1,10 @@ +id: 1791409317-72b900b2 +features: + - core +targets: + - cli +type: chore +bump: patch +description: replace the huh form library with an in-repo forms package +author: ThomasRooney +date: "2026-10-07" diff --git a/templates/perms.go b/templates/perms.go index a68f0cd1f..c79bef213 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -6,764 +6,779 @@ package templates import "io/fs" var fileMode = map[string]fs.FileMode{ - "templates/cli": 0o20000000755, - "templates/cli/.gitignore.stmpl": 0o644, - "templates/cli/CLAUDE.md": 0o644, - "templates/cli/README.md": 0o644, - "templates/cli/artifact_test.go.stmpl": 0o644, - "templates/cli/async_test.go.stmpl": 0o644, - "templates/cli/auth.go.stmpl": 0o644, - "templates/cli/auxiliary": 0o20000000755, - "templates/cli/auxiliary/cmd": 0o20000000755, - "templates/cli/auxiliary/cmd/gendocs": 0o20000000755, - "templates/cli/auxiliary/cmd/gendocs/main.go.stmpl": 0o644, - "templates/cli/auxiliary/cmd/gendocs/main_test.go.stmpl": 0o644, - "templates/cli/auxiliary/cmd/{{sanitizeCliName}}": 0o20000000755, - "templates/cli/auxiliary/cmd/{{sanitizeCliName}}/main.go.stmpl": 0o644, - "templates/cli/auxiliary/go.mod.stmpl": 0o644, - "templates/cli/auxiliary/internal": 0o20000000755, - "templates/cli/auxiliary/internal/client": 0o20000000755, - "templates/cli/auxiliary/internal/client/client.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/client/diagnostics.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/clierrors": 0o20000000755, - "templates/cli/auxiliary/internal/clierrors/clierrors.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/config": 0o20000000755, - "templates/cli/auxiliary/internal/config/config.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/config/keyring.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer": 0o20000000755, - "templates/cli/auxiliary/internal/explorer/explorer.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/styles.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/tree.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/tui.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil": 0o20000000755, - "templates/cli/auxiliary/internal/flagutil/async.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/dispatch.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/interactive": 0o20000000755, - "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output": 0o20000000755, - "templates/cli/auxiliary/internal/output/agentmode.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/artifact.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/async.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/binary.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/classify.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/color.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/defaulttimeout.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/errortable.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/exitcodes.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/jq.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/output.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/output_test.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/outputitems.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/paginated.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/pretty.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/progress.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/streaming.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/streamproject.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/testclient": 0o20000000755, - "templates/cli/auxiliary/internal/testclient/testclient.go.stmpl": 0o644, - "templates/cli/bodyschemas.go.stmpl": 0o644, - "templates/cli/catalog.go.stmpl": 0o644, - "templates/cli/cli_harness_test.go.stmpl": 0o644, - "templates/cli/config.ts": 0o644, - "templates/cli/configure.go.stmpl": 0o644, - "templates/cli/custom_register.go.stmpl": 0o644, - "templates/cli/dryrun_matrix_test.go.stmpl": 0o644, - "templates/cli/dryrun_response_test.go.stmpl": 0o644, - "templates/cli/examples.ts": 0o644, - "templates/cli/exclusions.ts": 0o644, - "templates/cli/explorer_tui_test.go.stmpl": 0o644, - "templates/cli/features.ts": 0o644, - "templates/cli/includes": 0o20000000755, - "templates/cli/includes/dependencies.ts": 0o644, - "templates/cli/includes/descriptions.ts": 0o644, - "templates/cli/includes/errors.ts": 0o644, - "templates/cli/includes/flags.ts": 0o644, - "templates/cli/includes/imports.ts": 0o644, - "templates/cli/includes/includes.ts": 0o644, - "templates/cli/includes/intents.ts": 0o644, - "templates/cli/includes/metadata.ts": 0o644, - "templates/cli/includes/readme-commands.ts": 0o644, - "templates/cli/includes/readme-examples.ts": 0o644, - "templates/cli/includes/release.ts": 0o644, - "templates/cli/includes/sanitization.ts": 0o644, - "templates/cli/includes/security.ts": 0o644, - "templates/cli/includes/serverusage.ts": 0o644, - "templates/cli/includes/templating.ts": 0o644, - "templates/cli/includes/test-assertions.ts": 0o644, - "templates/cli/includes/test-workflows.ts": 0o644, - "templates/cli/includes/tests.ts": 0o644, - "templates/cli/includes/unimplemented.ts": 0o644, - "templates/cli/includes/unions.ts": 0o644, - "templates/cli/includes/usage.ts": 0o644, - "templates/cli/includes/utils.ts": 0o644, - "templates/cli/intentcmd.go.stmpl": 0o644, - "templates/cli/intenthint_test.go.stmpl": 0o644, - "templates/cli/intents.go.stmpl": 0o644, - "templates/cli/intents_order_test.go.stmpl": 0o644, - "templates/cli/intentstream_test.go.stmpl": 0o644, - "templates/cli/interactive_policy_test.go.stmpl": 0o644, - "templates/cli/main.ts": 0o644, - "templates/cli/masking.go.stmpl": 0o644, - "templates/cli/mockserver.ts": 0o644, - "templates/cli/opcmd.go.stmpl": 0o644, - "templates/cli/opstream_test.go.stmpl": 0o644, - "templates/cli/positional_test.go.stmpl": 0o644, - "templates/cli/readme": 0o20000000755, - "templates/cli/readme.stmpl": 0o644, - "templates/cli/readme/agents.stmpl": 0o644, - "templates/cli/readme/completion.stmpl": 0o644, - "templates/cli/readme/diagnostics.stmpl": 0o644, - "templates/cli/readme/errors.stmpl": 0o644, - "templates/cli/readme/eventstreaming.stmpl": 0o644, - "templates/cli/readme/fileuploads.stmpl": 0o644, - "templates/cli/readme/globals.stmpl": 0o644, - "templates/cli/readme/installation.stmpl": 0o644, - "templates/cli/readme/jsonlstreaming.stmpl": 0o644, - "templates/cli/readme/output.stmpl": 0o644, - "templates/cli/readme/pagination.stmpl": 0o644, - "templates/cli/readme/retries.stmpl": 0o644, - "templates/cli/readme/security.stmpl": 0o644, - "templates/cli/readme/server.stmpl": 0o644, - "templates/cli/readme/stdinpiping.stmpl": 0o644, - "templates/cli/readme_examples_test.go.stmpl": 0o644, - "templates/cli/release": 0o20000000755, - "templates/cli/release/install.ps1.stmpl": 0o644, - "templates/cli/release/install.sh.stmpl": 0o644, - "templates/cli/release/release-workflow.yaml.stmpl": 0o644, - "templates/cli/request_progress_test.go.stmpl": 0o644, - "templates/cli/root.go.stmpl": 0o644, - "templates/cli/standalone.ts": 0o644, - "templates/cli/startup_tty_test.go.stmpl": 0o644, - "templates/cli/subroot.go.stmpl": 0o644, - "templates/cli/test.go.stmpl": 0o644, - "templates/cli/testfile.go.stmpl": 0o644, - "templates/cli/testhelper.go.stmpl": 0o644, - "templates/cli/testhelpers.go.stmpl": 0o644, - "templates/cli/tests": 0o20000000755, - "templates/cli/tests/basic-http": 0o20000000755, - "templates/cli/tests/basic-http/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/basic-http/serverflag_auto_additional_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials": 0o20000000755, - "templates/cli/tests/client-credentials-basic": 0o20000000755, - "templates/cli/tests/client-credentials-basic/serverflag_hidden_additional_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/dryrun_client_credentials_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/serverflag_none_additional_test.go.stmpl": 0o644, - "templates/cli/tests/common": 0o20000000755, - "templates/cli/tests/common/common_helpers_test.go": 0o644, - "templates/cli/tests/common/echo_server_test.go": 0o644, - "templates/cli/tests/custom-http": 0o20000000755, - "templates/cli/tests/custom-http/auth_test.go.stmpl": 0o644, - "templates/cli/tests/custom-http/defaulttimeout_test.go.stmpl": 0o644, - "templates/cli/tests/custom-http/serverflag_default_additional_test.go.stmpl": 0o644, - "templates/cli/tests/no-servers": 0o20000000755, - "templates/cli/tests/no-servers/no_servers_test.go.stmpl": 0o644, - "templates/cli/tests/oauth2-password": 0o20000000755, - "templates/cli/tests/oauth2-password/oauth2_password_hook_test.go.stmpl": 0o644, - "templates/cli/tests/oauth2-password/serverflag_visible_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary": 0o20000000755, - "templates/cli/tests/primary/agentmode_test.go.stmpl": 0o644, - "templates/cli/tests/primary/arghygiene_test.go.stmpl": 0o644, - "templates/cli/tests/primary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/auth_login_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/bodyinput_test.go.stmpl": 0o644, - "templates/cli/tests/primary/catalog_test.go.stmpl": 0o644, - "templates/cli/tests/primary/clierrors_test.go.stmpl": 0o644, - "templates/cli/tests/primary/collections_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/compact_help_test.go.stmpl": 0o644, - "templates/cli/tests/primary/configure_test.go.stmpl": 0o644, - "templates/cli/tests/primary/custom_commands_test.go.stmpl": 0o644, - "templates/cli/tests/primary/diagnostics_test.go.stmpl": 0o644, - "templates/cli/tests/primary/dispatch_test.go.stmpl": 0o644, - "templates/cli/tests/primary/dryrun_matrix_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/enums_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/errors_test.go.stmpl": 0o644, - "templates/cli/tests/primary/errortaxonomy_test.go.stmpl": 0o644, - "templates/cli/tests/primary/eventstreaming_test.go.stmpl": 0o644, - "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, - "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, - "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, - "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/intents_test.go.stmpl": 0o644, - "templates/cli/tests/primary/jsonlstreaming_test.go.stmpl": 0o644, - "templates/cli/tests/primary/keyring_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/keyring_timeout_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/methods_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/pagination_test.go.stmpl": 0o644, - "templates/cli/tests/primary/parameter_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/polling_test.go.stmpl": 0o644, - "templates/cli/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/requestshape_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/responselifetime_test.go.stmpl": 0o644, - "templates/cli/tests/primary/retries_test.go.stmpl": 0o644, - "templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644, - "templates/cli/tests/primary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644, - "templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644, - "templates/cli/tests/primary/toon_test.go.stmpl": 0o644, - "templates/cli/tests/primary/unions_additional_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary": 0o20000000755, - "templates/cli/tests/quaternary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary/transform_test.go.stmpl": 0o644, - "templates/cli/tests/secondary": 0o20000000755, - "templates/cli/tests/secondary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/pagination_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/redirects_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/security-options": 0o20000000755, - "templates/cli/tests/security-options/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary": 0o20000000755, - "templates/cli/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/parameters_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/retry_method_policy_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/retryflags_visibility_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/servers_test.go.stmpl": 0o644, - "templates/cli/testusage": 0o20000000755, - "templates/cli/testusage/go.mod.stmpl": 0o644, - "templates/cli/tsconfig.json": 0o644, - "templates/cli/usage": 0o20000000755, - "templates/cli/usage.go.stmpl": 0o644, - "templates/cli/usage/snippet.stmpl": 0o644, - "templates/cli/usage_test.go.stmpl": 0o644, - "templates/cli/version.go.stmpl": 0o644, - "templates/cli/whoami.go.stmpl": 0o644, - "templates/common": 0o20000000755, - "templates/common/HIDDEN": 0o644, - "templates/common/common": 0o20000000755, - "templates/common/common/annotations.ts": 0o644, - "templates/common/common/common.d.ts": 0o644, - "templates/common/common/config.ts": 0o644, - "templates/common/common/constants.ts": 0o644, - "templates/common/common/datatypes.ts": 0o644, - "templates/common/common/diff.ts": 0o644, - "templates/common/common/documentation.ts": 0o644, - "templates/common/common/envvars.ts": 0o644, - "templates/common/common/errors.ts": 0o644, - "templates/common/common/eventstreaming.ts": 0o644, - "templates/common/common/examples.ts": 0o644, - "templates/common/common/extensions.ts": 0o644, - "templates/common/common/faker.ts": 0o644, - "templates/common/common/features.d.ts": 0o644, - "templates/common/common/fieldDefs.ts": 0o644, - "templates/common/common/gitignore.ts": 0o644, - "templates/common/common/imports.ts": 0o644, - "templates/common/common/includes.ts": 0o644, - "templates/common/common/models.ts": 0o644, - "templates/common/common/modelusage.ts": 0o644, - "templates/common/common/open-unions.ts": 0o644, - "templates/common/common/operations.ts": 0o644, - "templates/common/common/polyfill.ts": 0o644, - "templates/common/common/sanitization.ts": 0o644, - "templates/common/common/sdkdocs.ts": 0o644, - "templates/common/common/security.ts": 0o644, - "templates/common/common/securityusage.ts": 0o644, - "templates/common/common/serverusage.ts": 0o644, - "templates/common/common/templating.ts": 0o644, - "templates/common/common/tests.ts": 0o644, - "templates/common/common/tsconfig.json": 0o644, - "templates/common/common/typeDefs.d.ts": 0o644, - "templates/common/common/typematching.ts": 0o644, - "templates/common/common/types.ts": 0o644, - "templates/common/common/utils.ts": 0o644, - "templates/common/common/webhooks.ts": 0o644, - "templates/common/containerReadme.stmpl": 0o644, - "templates/common/containerSetup.stmpl": 0o644, - "templates/common/contributing.md.stmpl": 0o644, - "templates/common/example.file.stmpl": 0o644, - "templates/common/generated-license": 0o20000000755, - "templates/common/generated-license/LICENSE": 0o644, - "templates/common/generated-license/NOTICE.stmpl": 0o644, - "templates/common/gitattributes.stmpl": 0o644, - "templates/common/go": 0o20000000755, - "templates/common/go/annotations.ts": 0o644, - "templates/common/go/discriminators.ts": 0o644, - "templates/common/go/getter_sanitization.ts": 0o644, - "templates/common/go/includes.ts": 0o644, - "templates/common/go/sanitization.ts": 0o644, - "templates/common/mcp": 0o20000000755, - "templates/common/mcp/readme": 0o20000000755, - "templates/common/mcp/readme/includes": 0o20000000755, - "templates/common/mcp/readme/includes/includes.ts": 0o644, - "templates/common/mcp/readme/includes/sections.ts": 0o644, - "templates/common/mcp/readme/sections": 0o20000000755, - "templates/common/mcp/readme/sections/common.ts": 0o644, - "templates/common/readme": 0o20000000755, - "templates/common/readme.stmpl": 0o644, - "templates/common/readme/dev-containers.stmpl": 0o644, - "templates/common/readme/globals.stmpl": 0o644, - "templates/common/readme/header.stmpl": 0o644, - "templates/common/readme/includes": 0o20000000755, - "templates/common/readme/includes/globals.ts": 0o644, - "templates/common/readme/includes/header.ts": 0o644, - "templates/common/readme/includes/includes.ts": 0o644, - "templates/common/readme/includes/operations.ts": 0o644, - "templates/common/readme/includes/readme.ts": 0o644, - "templates/common/readme/includes/sections.ts": 0o644, - "templates/common/readme/includes/toc.ts": 0o644, - "templates/common/readme/includes/usage.ts": 0o644, - "templates/common/readme/model.stmpl": 0o644, - "templates/common/readme/operation.example_after.stmpl": 0o644, - "templates/common/readme/operation.stmpl": 0o644, - "templates/common/readme/sdk.stmpl": 0o644, - "templates/common/readme/sections": 0o20000000755, - "templates/common/readme/sections/common.ts": 0o644, - "templates/common/readme/sections/debug.ts": 0o644, - "templates/common/readme/sections/errors.ts": 0o644, - "templates/common/readme/sections/eventstream.ts": 0o644, - "templates/common/readme/sections/file-upload.ts": 0o644, - "templates/common/readme/sections/http-client.ts": 0o644, - "templates/common/readme/sections/jsonl.ts": 0o644, - "templates/common/readme/sections/pagination.ts": 0o644, - "templates/common/readme/sections/react-query.ts": 0o644, - "templates/common/readme/sections/requirements.ts": 0o644, - "templates/common/readme/sections/retries.ts": 0o644, - "templates/common/readme/sections/security.ts": 0o644, - "templates/common/readme/sections/server.ts": 0o644, - "templates/common/readme/sections/standalone-funcs.ts": 0o644, - "templates/common/readme/server.stmpl": 0o644, - "templates/common/readme/snippet.stmpl": 0o644, - "templates/common/readme/summary.stmpl": 0o644, - "templates/common/readme/usage-container.stmpl": 0o644, - "templates/common/readme/usage.stmpl": 0o644, - "templates/common/typescript": 0o20000000755, - "templates/common/typescript/includes.ts": 0o644, - "templates/common/typescript/sanitization.ts": 0o644, - "templates/common/typescript/security.ts": 0o644, - "templates/common/usage": 0o20000000755, - "templates/common/usage.stmpl": 0o644, - "templates/common/usage/model_snippet.stmpl": 0o644, - "templates/csharp": 0o20000000755, - "templates/csharp/.csharpierignore": 0o644, - "templates/csharp/.gitignore.stmpl": 0o644, - "templates/csharp/auxiliary": 0o20000000755, - "templates/csharp/auxiliary/global.json.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getPackageId }}.sln.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/AnyDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/BigIntStrConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Constants.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/DecimalStrConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/EnumConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/FlexibleObjectDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/HeaderSerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenEnum.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenUnion.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IsoDateTimeSerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OpenEnumConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullable.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullableJsonConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/RequestBodySerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/ResponseBodyDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/BackoffStrategy.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/Retries.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/RetryConfig.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SecurityMetadata.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SpeakeasyMetadata.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/EventStream.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseEvent.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseParseException.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamExtensions.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamParser.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/URLBuilder.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/UnionDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Utilities.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/{{getDefaultHttpClient}}.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{ getPackageId }}.csproj.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/ResponseValidationException.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getBaseErrorClassName}}.cs.stmpl": 0o644, + "templates/cli": 0o20000000755, + "templates/cli/.gitignore.stmpl": 0o644, + "templates/cli/CLAUDE.md": 0o644, + "templates/cli/README.md": 0o644, + "templates/cli/artifact_test.go.stmpl": 0o644, + "templates/cli/async_test.go.stmpl": 0o644, + "templates/cli/auth.go.stmpl": 0o644, + "templates/cli/auxiliary": 0o20000000755, + "templates/cli/auxiliary/cmd": 0o20000000755, + "templates/cli/auxiliary/cmd/gendocs": 0o20000000755, + "templates/cli/auxiliary/cmd/gendocs/main.go.stmpl": 0o644, + "templates/cli/auxiliary/cmd/gendocs/main_test.go.stmpl": 0o644, + "templates/cli/auxiliary/cmd/{{sanitizeCliName}}": 0o20000000755, + "templates/cli/auxiliary/cmd/{{sanitizeCliName}}/main.go.stmpl": 0o644, + "templates/cli/auxiliary/go.mod.stmpl": 0o644, + "templates/cli/auxiliary/internal": 0o20000000755, + "templates/cli/auxiliary/internal/client": 0o20000000755, + "templates/cli/auxiliary/internal/client/client.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/client/diagnostics.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/clierrors": 0o20000000755, + "templates/cli/auxiliary/internal/clierrors/clierrors.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/config": 0o20000000755, + "templates/cli/auxiliary/internal/config/config.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/config/keyring.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer": 0o20000000755, + "templates/cli/auxiliary/internal/explorer/explorer.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/styles.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/tree.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/tui.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil": 0o20000000755, + "templates/cli/auxiliary/internal/flagutil/async.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/dispatch.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms": 0o20000000755, + "templates/cli/auxiliary/internal/forms/accessible.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/accessor.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/doc.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/field_input.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/field_select.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/field_text.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/form.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/group.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/keymap.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/option.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/selector.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/theme.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/wrap.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/interactive": 0o20000000755, + "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output": 0o20000000755, + "templates/cli/auxiliary/internal/output/agentmode.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/artifact.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/async.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/binary.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/classify.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/color.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/defaulttimeout.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/errortable.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/exitcodes.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/jq.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/output.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/output_test.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/outputitems.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/paginated.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/pretty.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/progress.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/streaming.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/streamproject.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/testclient": 0o20000000755, + "templates/cli/auxiliary/internal/testclient/testclient.go.stmpl": 0o644, + "templates/cli/bodyschemas.go.stmpl": 0o644, + "templates/cli/catalog.go.stmpl": 0o644, + "templates/cli/cli_harness_test.go.stmpl": 0o644, + "templates/cli/config.ts": 0o644, + "templates/cli/configure.go.stmpl": 0o644, + "templates/cli/custom_register.go.stmpl": 0o644, + "templates/cli/dryrun_matrix_test.go.stmpl": 0o644, + "templates/cli/dryrun_response_test.go.stmpl": 0o644, + "templates/cli/examples.ts": 0o644, + "templates/cli/exclusions.ts": 0o644, + "templates/cli/explorer_tui_test.go.stmpl": 0o644, + "templates/cli/features.ts": 0o644, + "templates/cli/includes": 0o20000000755, + "templates/cli/includes/dependencies.ts": 0o644, + "templates/cli/includes/descriptions.ts": 0o644, + "templates/cli/includes/errors.ts": 0o644, + "templates/cli/includes/flags.ts": 0o644, + "templates/cli/includes/imports.ts": 0o644, + "templates/cli/includes/includes.ts": 0o644, + "templates/cli/includes/intents.ts": 0o644, + "templates/cli/includes/metadata.ts": 0o644, + "templates/cli/includes/readme-commands.ts": 0o644, + "templates/cli/includes/readme-examples.ts": 0o644, + "templates/cli/includes/release.ts": 0o644, + "templates/cli/includes/sanitization.ts": 0o644, + "templates/cli/includes/security.ts": 0o644, + "templates/cli/includes/serverusage.ts": 0o644, + "templates/cli/includes/templating.ts": 0o644, + "templates/cli/includes/test-assertions.ts": 0o644, + "templates/cli/includes/test-workflows.ts": 0o644, + "templates/cli/includes/tests.ts": 0o644, + "templates/cli/includes/unimplemented.ts": 0o644, + "templates/cli/includes/unions.ts": 0o644, + "templates/cli/includes/usage.ts": 0o644, + "templates/cli/includes/utils.ts": 0o644, + "templates/cli/intentcmd.go.stmpl": 0o644, + "templates/cli/intenthint_test.go.stmpl": 0o644, + "templates/cli/intents.go.stmpl": 0o644, + "templates/cli/intents_order_test.go.stmpl": 0o644, + "templates/cli/intentstream_test.go.stmpl": 0o644, + "templates/cli/interactive_policy_test.go.stmpl": 0o644, + "templates/cli/main.ts": 0o644, + "templates/cli/masking.go.stmpl": 0o644, + "templates/cli/mockserver.ts": 0o644, + "templates/cli/opcmd.go.stmpl": 0o644, + "templates/cli/opstream_test.go.stmpl": 0o644, + "templates/cli/positional_test.go.stmpl": 0o644, + "templates/cli/readme": 0o20000000755, + "templates/cli/readme.stmpl": 0o644, + "templates/cli/readme/agents.stmpl": 0o644, + "templates/cli/readme/completion.stmpl": 0o644, + "templates/cli/readme/diagnostics.stmpl": 0o644, + "templates/cli/readme/errors.stmpl": 0o644, + "templates/cli/readme/eventstreaming.stmpl": 0o644, + "templates/cli/readme/fileuploads.stmpl": 0o644, + "templates/cli/readme/globals.stmpl": 0o644, + "templates/cli/readme/installation.stmpl": 0o644, + "templates/cli/readme/jsonlstreaming.stmpl": 0o644, + "templates/cli/readme/output.stmpl": 0o644, + "templates/cli/readme/pagination.stmpl": 0o644, + "templates/cli/readme/retries.stmpl": 0o644, + "templates/cli/readme/security.stmpl": 0o644, + "templates/cli/readme/server.stmpl": 0o644, + "templates/cli/readme/stdinpiping.stmpl": 0o644, + "templates/cli/readme_examples_test.go.stmpl": 0o644, + "templates/cli/release": 0o20000000755, + "templates/cli/release/install.ps1.stmpl": 0o644, + "templates/cli/release/install.sh.stmpl": 0o644, + "templates/cli/release/release-workflow.yaml.stmpl": 0o644, + "templates/cli/request_progress_test.go.stmpl": 0o644, + "templates/cli/root.go.stmpl": 0o644, + "templates/cli/standalone.ts": 0o644, + "templates/cli/startup_tty_test.go.stmpl": 0o644, + "templates/cli/subroot.go.stmpl": 0o644, + "templates/cli/test.go.stmpl": 0o644, + "templates/cli/testfile.go.stmpl": 0o644, + "templates/cli/testhelper.go.stmpl": 0o644, + "templates/cli/testhelpers.go.stmpl": 0o644, + "templates/cli/tests": 0o20000000755, + "templates/cli/tests/basic-http": 0o20000000755, + "templates/cli/tests/basic-http/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/basic-http/serverflag_auto_additional_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials": 0o20000000755, + "templates/cli/tests/client-credentials-basic": 0o20000000755, + "templates/cli/tests/client-credentials-basic/serverflag_hidden_additional_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/dryrun_client_credentials_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/serverflag_none_additional_test.go.stmpl": 0o644, + "templates/cli/tests/common": 0o20000000755, + "templates/cli/tests/common/common_helpers_test.go": 0o644, + "templates/cli/tests/common/echo_server_test.go": 0o644, + "templates/cli/tests/custom-http": 0o20000000755, + "templates/cli/tests/custom-http/auth_test.go.stmpl": 0o644, + "templates/cli/tests/custom-http/defaulttimeout_test.go.stmpl": 0o644, + "templates/cli/tests/custom-http/serverflag_default_additional_test.go.stmpl": 0o644, + "templates/cli/tests/no-servers": 0o20000000755, + "templates/cli/tests/no-servers/no_servers_test.go.stmpl": 0o644, + "templates/cli/tests/oauth2-password": 0o20000000755, + "templates/cli/tests/oauth2-password/oauth2_password_hook_test.go.stmpl": 0o644, + "templates/cli/tests/oauth2-password/serverflag_visible_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary": 0o20000000755, + "templates/cli/tests/primary/agentmode_test.go.stmpl": 0o644, + "templates/cli/tests/primary/arghygiene_test.go.stmpl": 0o644, + "templates/cli/tests/primary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/auth_login_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/bodyinput_test.go.stmpl": 0o644, + "templates/cli/tests/primary/catalog_test.go.stmpl": 0o644, + "templates/cli/tests/primary/clierrors_test.go.stmpl": 0o644, + "templates/cli/tests/primary/collections_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/compact_help_test.go.stmpl": 0o644, + "templates/cli/tests/primary/configure_test.go.stmpl": 0o644, + "templates/cli/tests/primary/custom_commands_test.go.stmpl": 0o644, + "templates/cli/tests/primary/diagnostics_test.go.stmpl": 0o644, + "templates/cli/tests/primary/dispatch_test.go.stmpl": 0o644, + "templates/cli/tests/primary/dryrun_matrix_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/enums_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/errors_test.go.stmpl": 0o644, + "templates/cli/tests/primary/errortaxonomy_test.go.stmpl": 0o644, + "templates/cli/tests/primary/eventstreaming_test.go.stmpl": 0o644, + "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, + "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, + "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/intents_test.go.stmpl": 0o644, + "templates/cli/tests/primary/jsonlstreaming_test.go.stmpl": 0o644, + "templates/cli/tests/primary/keyring_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/keyring_timeout_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/methods_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/pagination_test.go.stmpl": 0o644, + "templates/cli/tests/primary/parameter_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/polling_test.go.stmpl": 0o644, + "templates/cli/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/requestshape_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/responselifetime_test.go.stmpl": 0o644, + "templates/cli/tests/primary/retries_test.go.stmpl": 0o644, + "templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644, + "templates/cli/tests/primary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644, + "templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644, + "templates/cli/tests/primary/toon_test.go.stmpl": 0o644, + "templates/cli/tests/primary/unions_additional_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary": 0o20000000755, + "templates/cli/tests/quaternary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary/transform_test.go.stmpl": 0o644, + "templates/cli/tests/secondary": 0o20000000755, + "templates/cli/tests/secondary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/pagination_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/redirects_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/security-options": 0o20000000755, + "templates/cli/tests/security-options/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary": 0o20000000755, + "templates/cli/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/parameters_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/retry_method_policy_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/retryflags_visibility_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/servers_test.go.stmpl": 0o644, + "templates/cli/testusage": 0o20000000755, + "templates/cli/testusage/go.mod.stmpl": 0o644, + "templates/cli/tsconfig.json": 0o644, + "templates/cli/usage": 0o20000000755, + "templates/cli/usage.go.stmpl": 0o644, + "templates/cli/usage/snippet.stmpl": 0o644, + "templates/cli/usage_test.go.stmpl": 0o644, + "templates/cli/version.go.stmpl": 0o644, + "templates/cli/whoami.go.stmpl": 0o644, + "templates/common": 0o20000000755, + "templates/common/HIDDEN": 0o644, + "templates/common/common": 0o20000000755, + "templates/common/common/annotations.ts": 0o644, + "templates/common/common/common.d.ts": 0o644, + "templates/common/common/config.ts": 0o644, + "templates/common/common/constants.ts": 0o644, + "templates/common/common/datatypes.ts": 0o644, + "templates/common/common/diff.ts": 0o644, + "templates/common/common/documentation.ts": 0o644, + "templates/common/common/envvars.ts": 0o644, + "templates/common/common/errors.ts": 0o644, + "templates/common/common/eventstreaming.ts": 0o644, + "templates/common/common/examples.ts": 0o644, + "templates/common/common/extensions.ts": 0o644, + "templates/common/common/faker.ts": 0o644, + "templates/common/common/features.d.ts": 0o644, + "templates/common/common/fieldDefs.ts": 0o644, + "templates/common/common/gitignore.ts": 0o644, + "templates/common/common/imports.ts": 0o644, + "templates/common/common/includes.ts": 0o644, + "templates/common/common/models.ts": 0o644, + "templates/common/common/modelusage.ts": 0o644, + "templates/common/common/open-unions.ts": 0o644, + "templates/common/common/operations.ts": 0o644, + "templates/common/common/polyfill.ts": 0o644, + "templates/common/common/sanitization.ts": 0o644, + "templates/common/common/sdkdocs.ts": 0o644, + "templates/common/common/security.ts": 0o644, + "templates/common/common/securityusage.ts": 0o644, + "templates/common/common/serverusage.ts": 0o644, + "templates/common/common/templating.ts": 0o644, + "templates/common/common/tests.ts": 0o644, + "templates/common/common/tsconfig.json": 0o644, + "templates/common/common/typeDefs.d.ts": 0o644, + "templates/common/common/typematching.ts": 0o644, + "templates/common/common/types.ts": 0o644, + "templates/common/common/utils.ts": 0o644, + "templates/common/common/webhooks.ts": 0o644, + "templates/common/containerReadme.stmpl": 0o644, + "templates/common/containerSetup.stmpl": 0o644, + "templates/common/contributing.md.stmpl": 0o644, + "templates/common/example.file.stmpl": 0o644, + "templates/common/generated-license": 0o20000000755, + "templates/common/generated-license/LICENSE": 0o644, + "templates/common/generated-license/NOTICE.stmpl": 0o644, + "templates/common/gitattributes.stmpl": 0o644, + "templates/common/go": 0o20000000755, + "templates/common/go/annotations.ts": 0o644, + "templates/common/go/discriminators.ts": 0o644, + "templates/common/go/getter_sanitization.ts": 0o644, + "templates/common/go/includes.ts": 0o644, + "templates/common/go/sanitization.ts": 0o644, + "templates/common/mcp": 0o20000000755, + "templates/common/mcp/readme": 0o20000000755, + "templates/common/mcp/readme/includes": 0o20000000755, + "templates/common/mcp/readme/includes/includes.ts": 0o644, + "templates/common/mcp/readme/includes/sections.ts": 0o644, + "templates/common/mcp/readme/sections": 0o20000000755, + "templates/common/mcp/readme/sections/common.ts": 0o644, + "templates/common/readme": 0o20000000755, + "templates/common/readme.stmpl": 0o644, + "templates/common/readme/dev-containers.stmpl": 0o644, + "templates/common/readme/globals.stmpl": 0o644, + "templates/common/readme/header.stmpl": 0o644, + "templates/common/readme/includes": 0o20000000755, + "templates/common/readme/includes/globals.ts": 0o644, + "templates/common/readme/includes/header.ts": 0o644, + "templates/common/readme/includes/includes.ts": 0o644, + "templates/common/readme/includes/operations.ts": 0o644, + "templates/common/readme/includes/readme.ts": 0o644, + "templates/common/readme/includes/sections.ts": 0o644, + "templates/common/readme/includes/toc.ts": 0o644, + "templates/common/readme/includes/usage.ts": 0o644, + "templates/common/readme/model.stmpl": 0o644, + "templates/common/readme/operation.example_after.stmpl": 0o644, + "templates/common/readme/operation.stmpl": 0o644, + "templates/common/readme/sdk.stmpl": 0o644, + "templates/common/readme/sections": 0o20000000755, + "templates/common/readme/sections/common.ts": 0o644, + "templates/common/readme/sections/debug.ts": 0o644, + "templates/common/readme/sections/errors.ts": 0o644, + "templates/common/readme/sections/eventstream.ts": 0o644, + "templates/common/readme/sections/file-upload.ts": 0o644, + "templates/common/readme/sections/http-client.ts": 0o644, + "templates/common/readme/sections/jsonl.ts": 0o644, + "templates/common/readme/sections/pagination.ts": 0o644, + "templates/common/readme/sections/react-query.ts": 0o644, + "templates/common/readme/sections/requirements.ts": 0o644, + "templates/common/readme/sections/retries.ts": 0o644, + "templates/common/readme/sections/security.ts": 0o644, + "templates/common/readme/sections/server.ts": 0o644, + "templates/common/readme/sections/standalone-funcs.ts": 0o644, + "templates/common/readme/server.stmpl": 0o644, + "templates/common/readme/snippet.stmpl": 0o644, + "templates/common/readme/summary.stmpl": 0o644, + "templates/common/readme/usage-container.stmpl": 0o644, + "templates/common/readme/usage.stmpl": 0o644, + "templates/common/typescript": 0o20000000755, + "templates/common/typescript/includes.ts": 0o644, + "templates/common/typescript/sanitization.ts": 0o644, + "templates/common/typescript/security.ts": 0o644, + "templates/common/usage": 0o20000000755, + "templates/common/usage.stmpl": 0o644, + "templates/common/usage/model_snippet.stmpl": 0o644, + "templates/csharp": 0o20000000755, + "templates/csharp/.csharpierignore": 0o644, + "templates/csharp/.gitignore.stmpl": 0o644, + "templates/csharp/auxiliary": 0o20000000755, + "templates/csharp/auxiliary/global.json.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getPackageId }}.sln.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/AnyDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/BigIntStrConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Constants.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/DecimalStrConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/EnumConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/FlexibleObjectDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/HeaderSerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenEnum.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenUnion.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IsoDateTimeSerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OpenEnumConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullable.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullableJsonConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/RequestBodySerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/ResponseBodyDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/BackoffStrategy.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/Retries.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/RetryConfig.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SecurityMetadata.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SpeakeasyMetadata.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/EventStream.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseEvent.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseParseException.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamExtensions.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamParser.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/URLBuilder.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/UnionDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Utilities.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/{{getDefaultHttpClient}}.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{ getPackageId }}.csproj.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/ResponseValidationException.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getBaseErrorClassName}}.cs.stmpl": 0o644, "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getDefaultErrorClassName}}.cs.stmpl": 0o644, - "templates/csharp/config.ts": 0o644, - "templates/csharp/enum-extension.cs.stmpl": 0o644, - "templates/csharp/enum-open.cs.stmpl": 0o644, - "templates/csharp/enum.cs.stmpl": 0o644, - "templates/csharp/error.cs.stmpl": 0o644, - "templates/csharp/examples.ts": 0o644, - "templates/csharp/exclusions.ts": 0o644, - "templates/csharp/features.ts": 0o644, - "templates/csharp/hooks": 0o20000000755, - "templates/csharp/hooks/clientcredentials.cs.stmpl": 0o644, - "templates/csharp/hooks/hooks.cs.stmpl": 0o644, - "templates/csharp/hooks/hooks.ts": 0o644, - "templates/csharp/hooks/oauth2scopes.cs.stmpl": 0o644, - "templates/csharp/hooks/registration.cs.stmpl": 0o644, - "templates/csharp/hooks/types.cs.stmpl": 0o644, - "templates/csharp/includes": 0o20000000755, - "templates/csharp/includes/annotations.ts": 0o644, - "templates/csharp/includes/auxiliary.ts": 0o644, - "templates/csharp/includes/compat.ts": 0o644, - "templates/csharp/includes/dependencies.ts": 0o644, - "templates/csharp/includes/documentation.ts": 0o644, - "templates/csharp/includes/enums.ts": 0o644, - "templates/csharp/includes/errors.ts": 0o644, - "templates/csharp/includes/imports.ts": 0o644, - "templates/csharp/includes/includes.ts": 0o644, - "templates/csharp/includes/models.ts": 0o644, - "templates/csharp/includes/modelusage.ts": 0o644, - "templates/csharp/includes/namespaces.ts": 0o644, - "templates/csharp/includes/nuget.ts": 0o644, - "templates/csharp/includes/open-unions.ts": 0o644, - "templates/csharp/includes/pagination.ts": 0o644, - "templates/csharp/includes/responses.ts": 0o644, - "templates/csharp/includes/sanitization.ts": 0o644, - "templates/csharp/includes/security.ts": 0o644, - "templates/csharp/includes/serverusage.ts": 0o644, - "templates/csharp/includes/templating.ts": 0o644, - "templates/csharp/includes/tests.ts": 0o644, - "templates/csharp/includes/unimplemented.ts": 0o644, - "templates/csharp/includes/utils.ts": 0o644, - "templates/csharp/main.ts": 0o644, - "templates/csharp/method.cs.stmpl": 0o644, - "templates/csharp/mockserver.ts": 0o644, - "templates/csharp/model.cs.stmpl": 0o644, - "templates/csharp/modelfile.cs.stmpl": 0o644, - "templates/csharp/pagination.cs.stmpl": 0o644, - "templates/csharp/readme": 0o20000000755, - "templates/csharp/readme/errors.stmpl": 0o644, - "templates/csharp/readme/eventstream.stmpl": 0o644, - "templates/csharp/readme/http_client.stmpl": 0o644, - "templates/csharp/readme/installation.stmpl": 0o644, - "templates/csharp/readme/nuget.stmpl": 0o644, - "templates/csharp/readme/pagination.stmpl": 0o644, - "templates/csharp/readme/retries.stmpl": 0o644, - "templates/csharp/readme/security.stmpl": 0o644, - "templates/csharp/sdk.cs.stmpl": 0o644, - "templates/csharp/sdkconfig.cs.stmpl": 0o644, - "templates/csharp/servers.cs.stmpl": 0o644, - "templates/csharp/standalone.ts": 0o644, - "templates/csharp/subsdk.cs.stmpl": 0o644, - "templates/csharp/test.cs.stmpl": 0o644, - "templates/csharp/testfile.cs.stmpl": 0o644, - "templates/csharp/testhelper.cs.stmpl": 0o644, - "templates/csharp/testhelpers.cs.stmpl": 0o644, - "templates/csharp/testproject": 0o20000000755, - "templates/csharp/testproject/Program.cs.stmpl": 0o644, - "templates/csharp/testproject/testproject.csproj.stmpl": 0o644, - "templates/csharp/tests": 0o20000000755, - "templates/csharp/tests/Common": 0o20000000755, - "templates/csharp/tests/Common/CommonHelpers.cs.stmpl": 0o644, - "templates/csharp/tests/Common/Tests.csproj.stmpl": 0o644, - "templates/csharp/tests/basic-http": 0o20000000755, - "templates/csharp/tests/basic-http/AuthAdditionalTests.cs": 0o644, - "templates/csharp/tests/client-credentials": 0o20000000755, - "templates/csharp/tests/client-credentials-basic": 0o20000000755, - "templates/csharp/tests/client-credentials-basic/ClientCredentialsBasicHookShould.cs": 0o644, - "templates/csharp/tests/client-credentials/ClientCredentialsHookShould.cs": 0o644, - "templates/csharp/tests/custom-http": 0o20000000755, - "templates/csharp/tests/custom-http/AuthShould.cs": 0o644, - "templates/csharp/tests/primary": 0o20000000755, - "templates/csharp/tests/primary/AuthShould.cs": 0o644, - "templates/csharp/tests/primary/CancellationTokenShould.cs": 0o644, - "templates/csharp/tests/primary/CollectionsShould.cs": 0o644, - "templates/csharp/tests/primary/CustomClientShould.cs": 0o644, - "templates/csharp/tests/primary/ErrorsShould.cs": 0o644, - "templates/csharp/tests/primary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/FlatteningShould.cs": 0o644, - "templates/csharp/tests/primary/GlobalsAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/HeadersShould.cs": 0o644, - "templates/csharp/tests/primary/Helpers.cs": 0o644, - "templates/csharp/tests/primary/HooksShould.cs.stmpl": 0o644, - "templates/csharp/tests/primary/MultiLevelShould.cs": 0o644, - "templates/csharp/tests/primary/OpenEnumsShould.cs": 0o644, - "templates/csharp/tests/primary/OpenUnionsShould.cs": 0o644, - "templates/csharp/tests/primary/OptionalNullableFieldsShould.cs": 0o644, - "templates/csharp/tests/primary/PaginationShould.cs": 0o644, - "templates/csharp/tests/primary/ParametersShould.cs": 0o644, - "templates/csharp/tests/primary/RequestBodiesShould.cs": 0o644, - "templates/csharp/tests/primary/ResponseBodiesShould.cs": 0o644, - "templates/csharp/tests/primary/RetriesShould.cs": 0o644, - "templates/csharp/tests/primary/ServersShould.cs": 0o644, - "templates/csharp/tests/primary/SseStreamParserAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/TelemetryShould.cs.stmpl": 0o644, - "templates/csharp/tests/primary/UnionsShould.cs": 0o644, - "templates/csharp/tests/primary/testUpload.json": 0o644, - "templates/csharp/tests/quaternary": 0o20000000755, - "templates/csharp/tests/quaternary/AuthShould.cs": 0o644, - "templates/csharp/tests/quaternary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/quaternary/Helpers.cs": 0o644, - "templates/csharp/tests/quaternary/OpenEnumsShould.cs": 0o644, - "templates/csharp/tests/quaternary/OptionalNullableFieldsShould.cs": 0o644, - "templates/csharp/tests/quaternary/ParametersShould.cs": 0o644, - "templates/csharp/tests/quaternary/RequestBodiesShould.cs": 0o644, - "templates/csharp/tests/quaternary/ServersShould.cs": 0o644, - "templates/csharp/tests/quaternary/UnionsShould.cs": 0o644, - "templates/csharp/tests/secondary": 0o20000000755, - "templates/csharp/tests/secondary/AuthShould.cs": 0o644, - "templates/csharp/tests/secondary/HeadersShould.cs": 0o644, - "templates/csharp/tests/secondary/PaginationShould.cs": 0o644, - "templates/csharp/tests/secondary/ParametersShould.cs": 0o644, - "templates/csharp/tests/secondary/ResponseBodiesShould.cs": 0o644, - "templates/csharp/tests/secondary/ServersShould.cs": 0o644, - "templates/csharp/tests/security-options": 0o20000000755, - "templates/csharp/tests/security-options/AuthAdditionalShould.cs": 0o644, - "templates/csharp/tests/tertiary": 0o20000000755, - "templates/csharp/tests/tertiary/AuthShould.cs": 0o644, - "templates/csharp/tests/tertiary/CustomClientShould.cs": 0o644, - "templates/csharp/tests/tertiary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/tertiary/Helpers.cs": 0o644, - "templates/csharp/tests/tertiary/ParametersShould.cs": 0o644, - "templates/csharp/tests/tertiary/SchemaValidationShould.cs": 0o644, - "templates/csharp/tests/tertiary/ServersShould.cs": 0o644, - "templates/csharp/testusage": 0o20000000755, - "templates/csharp/testusage/testusage.csproj.stmpl": 0o644, - "templates/csharp/tsconfig.json": 0o644, - "templates/csharp/union.cs.stmpl": 0o644, - "templates/csharp/usage": 0o20000000755, - "templates/csharp/usage/block.stmpl": 0o644, - "templates/csharp/usage/call.stmpl": 0o644, - "templates/csharp/usage/errors.stmpl": 0o644, - "templates/csharp/usage/eventstream.stmpl": 0o644, - "templates/csharp/usage/init.stmpl": 0o644, - "templates/csharp/usage/model_snippet.stmpl": 0o644, - "templates/csharp/usage/pagination.stmpl": 0o644, - "templates/csharp/usage/result.stmpl": 0o644, - "templates/csharp/usage/retries.stmpl": 0o644, - "templates/csharp/usage/snippet.stmpl": 0o644, - "templates/csharp/usage/test.stmpl": 0o644, - "templates/go": 0o20000000755, - "templates/go/.gitignore.stmpl": 0o644, - "templates/go/auxiliary": 0o20000000755, - "templates/go/auxiliary/go.mod.stmpl": 0o644, - "templates/go/auxiliary/tests": 0o20000000755, - "templates/go/auxiliary/tests/files.go.stmpl": 0o644, - "templates/go/auxiliary/tests/helpers.go.stmpl": 0o644, - "templates/go/auxiliary/tests/testclient.go.stmpl": 0o644, - "templates/go/auxiliary/{{getErrorsLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getErrorsLocation}}/responsevalidationerror.go.stmpl": 0o644, - "templates/go/auxiliary/{{getErrorsLocation}}/{{getDefaultErrorFileName}}.go.stmpl": 0o644, - "templates/go/auxiliary/{{getNullableLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable.go.stmpl": 0o644, - "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable_test.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getPollingLocation}}/config.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}/errors.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}/option.go.stmpl": 0o644, - "templates/go/auxiliary/{{getRetryLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getRetryLocation}}/config.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/bigint.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/date.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/datetime.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/decimal.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/jsonl": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/jsonl/jsonl.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/pointers.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/stream": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getUtilsLocation}}/contenttype.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/env.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/form.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/headers.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/jq.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/json.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_credentials.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_sessions.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/pathparams.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/queryparams.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/requestbody.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/retries.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/security.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/union.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/union_test.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/utils.go.stmpl": 0o644, - "templates/go/config.ts": 0o644, - "templates/go/devcontainers": 0o20000000755, - "templates/go/devcontainers/config.stmpl": 0o644, - "templates/go/devcontainers/setup.stmpl": 0o644, - "templates/go/docs": 0o20000000755, - "templates/go/docs/types": 0o20000000755, - "templates/go/docs/types/date.stmpl": 0o644, - "templates/go/enum.go.stmpl": 0o644, - "templates/go/error.go.stmpl": 0o644, - "templates/go/examples.ts": 0o644, - "templates/go/exclusions.ts": 0o644, - "templates/go/features.ts": 0o644, - "templates/go/hooks": 0o20000000755, - "templates/go/hooks/clientcredentials.go.stmpl": 0o644, - "templates/go/hooks/hooks.go.stmpl": 0o644, - "templates/go/hooks/hooks.ts": 0o644, - "templates/go/hooks/oauth2_password.go.stmpl": 0o644, - "templates/go/hooks/oauth2scopes.go.stmpl": 0o644, - "templates/go/hooks/registration.go.stmpl": 0o644, - "templates/go/includes": 0o20000000755, - "templates/go/includes/dependencies.ts": 0o644, - "templates/go/includes/documentation.ts": 0o644, - "templates/go/includes/enums.ts": 0o644, - "templates/go/includes/errors.ts": 0o644, - "templates/go/includes/eventstreaming.ts": 0o644, - "templates/go/includes/imports.ts": 0o644, - "templates/go/includes/includes.ts": 0o644, - "templates/go/includes/modelusage.ts": 0o644, - "templates/go/includes/open-unions.ts": 0o644, - "templates/go/includes/operations.ts": 0o644, - "templates/go/includes/options.ts": 0o644, - "templates/go/includes/polling.ts": 0o644, - "templates/go/includes/responses.ts": 0o644, - "templates/go/includes/retractions.ts": 0o644, - "templates/go/includes/sanitization.ts": 0o644, - "templates/go/includes/serverusage.ts": 0o644, - "templates/go/includes/templating.ts": 0o644, - "templates/go/includes/tests.ts": 0o644, - "templates/go/includes/transform.ts": 0o644, - "templates/go/includes/unions.ts": 0o644, - "templates/go/includes/utils.ts": 0o644, - "templates/go/main.ts": 0o644, - "templates/go/method.go.stmpl": 0o644, - "templates/go/method_options.go.stmpl": 0o644, - "templates/go/method_options_setup.go.stmpl": 0o644, - "templates/go/mockserver.ts": 0o644, - "templates/go/model.go.stmpl": 0o644, - "templates/go/modelfile.go.stmpl": 0o644, - "templates/go/options.go.stmpl": 0o644, - "templates/go/pagination.go.stmpl": 0o644, - "templates/go/readme": 0o20000000755, - "templates/go/readme/errors.stmpl": 0o644, - "templates/go/readme/eventstream.stmpl": 0o644, - "templates/go/readme/http_client.stmpl": 0o644, - "templates/go/readme/installation.stmpl": 0o644, - "templates/go/readme/jsonl.stmpl": 0o644, - "templates/go/readme/options.stmpl": 0o644, - "templates/go/readme/pagination.stmpl": 0o644, - "templates/go/readme/retries.stmpl": 0o644, - "templates/go/readme/security.stmpl": 0o644, - "templates/go/readme/types.stmpl": 0o644, - "templates/go/sdk.go.stmpl": 0o644, - "templates/go/sdkconfiguration.go.stmpl": 0o644, - "templates/go/standalone.ts": 0o644, - "templates/go/subsdk.go.stmpl": 0o644, - "templates/go/test.go.stmpl": 0o644, - "templates/go/testfile.go.stmpl": 0o644, - "templates/go/testhelper.go.stmpl": 0o644, - "templates/go/testhelpers.go.stmpl": 0o644, - "templates/go/testproject": 0o20000000755, - "templates/go/testproject/go.mod.stmpl": 0o644, - "templates/go/tests": 0o20000000755, - "templates/go/tests/basic-http": 0o20000000755, - "templates/go/tests/basic-http/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/client-credentials": 0o20000000755, - "templates/go/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, - "templates/go/tests/common": 0o20000000755, - "templates/go/tests/common/common_helpers_test.go": 0o644, - "templates/go/tests/custom-http": 0o20000000755, - "templates/go/tests/custom-http/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/oauth2-password": 0o20000000755, - "templates/go/tests/oauth2-password/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary": 0o20000000755, - "templates/go/tests/primary/allow_reserved_query_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/collections_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/custom_code_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/custom_omitempty_vs_builtin_test.go.stmpl": 0o644, - "templates/go/tests/primary/customclient_test.go.stmpl": 0o644, - "templates/go/tests/primary/enums_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/errors_test.go.stmpl": 0o644, - "templates/go/tests/primary/eventstreaming_test.go.stmpl": 0o644, - "templates/go/tests/primary/flattening_test.go.stmpl": 0o644, - "templates/go/tests/primary/globals_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/headers_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/helpers_test.go.stmpl": 0o644, - "templates/go/tests/primary/hooks_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/jsonlstreaming_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/open_unions_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/optional_field_serialization_test.go.stmpl": 0o644, - "templates/go/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/pagination_test.go.stmpl": 0o644, - "templates/go/tests/primary/parameter_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/polling_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/retries_test.go.stmpl": 0o644, - "templates/go/tests/primary/security_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/servers_test.go.stmpl": 0o644, - "templates/go/tests/primary/telemetry_test.go.stmpl": 0o644, - "templates/go/tests/primary/testdata": 0o20000000755, - "templates/go/tests/primary/testdata/testUpload.json": 0o644, - "templates/go/tests/primary/unions_additional_test.go.stmpl": 0o644, - "templates/go/tests/quaternary": 0o20000000755, - "templates/go/tests/quaternary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/quaternary/servers_test.go.stmpl": 0o644, - "templates/go/tests/quaternary/transform_test.go.stmpl": 0o644, - "templates/go/tests/secondary": 0o20000000755, - "templates/go/tests/secondary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/headers_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/json_omitempty_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/jsonlstreaming_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/pagination_test.go.stmpl": 0o644, - "templates/go/tests/secondary/redirects_followed_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/servers_test.go.stmpl": 0o644, - "templates/go/tests/security-options": 0o20000000755, - "templates/go/tests/security-options/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary": 0o20000000755, - "templates/go/tests/tertiary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/servers_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/unions_generic_additional_test.go.stmpl": 0o644, - "templates/go/testusage": 0o20000000755, - "templates/go/testusage/go.mod.stmpl": 0o644, - "templates/go/tsconfig.json": 0o644, - "templates/go/union.go.stmpl": 0o644, - "templates/go/usage": 0o20000000755, - "templates/go/usage/block.stmpl": 0o644, - "templates/go/usage/call.stmpl": 0o644, - "templates/go/usage/eventstream.stmpl": 0o644, - "templates/go/usage/init.stmpl": 0o644, - "templates/go/usage/jsonl.stmpl": 0o644, - "templates/go/usage/model_snippet.stmpl": 0o644, - "templates/go/usage/pagination.stmpl": 0o644, - "templates/go/usage/result.stmpl": 0o644, - "templates/go/usage/retries.stmpl": 0o644, - "templates/go/usage/snippet.stmpl": 0o644, - "templates/go/usage/test.stmpl": 0o644, - "templates/java": 0o20000000755, - "templates/java/SUNSET": 0o644, - "templates/java/config.ts": 0o644, - "templates/javav2": 0o20000000755, - "templates/javav2/.gitignore.stmpl": 0o644, - "templates/javav2/ClientCredentialsHook.java.stmpl": 0o644, - "templates/javav2/HIDDEN": 0o644, - "templates/javav2/OAuth2PasswordHook.java.stmpl": 0o644, - "templates/javav2/OAuth2Scopes.java.stmpl": 0o644, - "templates/javav2/SDKHooks.java.stmpl": 0o644, - "templates/javav2/async-method.java.stmpl": 0o644, - "templates/javav2/async-sdk.java.stmpl": 0o644, - "templates/javav2/async-subsdk.java.stmpl": 0o644, - "templates/javav2/auxiliary": 0o20000000755, - "templates/javav2/auxiliary/build.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/docs": 0o20000000755, - "templates/javav2/auxiliary/docs/utils": 0o20000000755, - "templates/javav2/auxiliary/docs/utils/Blob.md.stmpl": 0o644, - "templates/javav2/auxiliary/gradle": 0o20000000755, - "templates/javav2/auxiliary/gradle.properties.stmpl": 0o644, - "templates/javav2/auxiliary/gradle/wrapper": 0o20000000755, - "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.jar": 0o644, - "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.properties": 0o644, - "templates/javav2/auxiliary/gradlew": 0o755, - "templates/javav2/auxiliary/gradlew.bat": 0o755, - "templates/javav2/auxiliary/publishing.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/settings.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/spring": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/build.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java": 0o20000000755, + "templates/csharp/config.ts": 0o644, + "templates/csharp/enum-extension.cs.stmpl": 0o644, + "templates/csharp/enum-open.cs.stmpl": 0o644, + "templates/csharp/enum.cs.stmpl": 0o644, + "templates/csharp/error.cs.stmpl": 0o644, + "templates/csharp/examples.ts": 0o644, + "templates/csharp/exclusions.ts": 0o644, + "templates/csharp/features.ts": 0o644, + "templates/csharp/hooks": 0o20000000755, + "templates/csharp/hooks/clientcredentials.cs.stmpl": 0o644, + "templates/csharp/hooks/hooks.cs.stmpl": 0o644, + "templates/csharp/hooks/hooks.ts": 0o644, + "templates/csharp/hooks/oauth2scopes.cs.stmpl": 0o644, + "templates/csharp/hooks/registration.cs.stmpl": 0o644, + "templates/csharp/hooks/types.cs.stmpl": 0o644, + "templates/csharp/includes": 0o20000000755, + "templates/csharp/includes/annotations.ts": 0o644, + "templates/csharp/includes/auxiliary.ts": 0o644, + "templates/csharp/includes/compat.ts": 0o644, + "templates/csharp/includes/dependencies.ts": 0o644, + "templates/csharp/includes/documentation.ts": 0o644, + "templates/csharp/includes/enums.ts": 0o644, + "templates/csharp/includes/errors.ts": 0o644, + "templates/csharp/includes/imports.ts": 0o644, + "templates/csharp/includes/includes.ts": 0o644, + "templates/csharp/includes/models.ts": 0o644, + "templates/csharp/includes/modelusage.ts": 0o644, + "templates/csharp/includes/namespaces.ts": 0o644, + "templates/csharp/includes/nuget.ts": 0o644, + "templates/csharp/includes/open-unions.ts": 0o644, + "templates/csharp/includes/pagination.ts": 0o644, + "templates/csharp/includes/responses.ts": 0o644, + "templates/csharp/includes/sanitization.ts": 0o644, + "templates/csharp/includes/security.ts": 0o644, + "templates/csharp/includes/serverusage.ts": 0o644, + "templates/csharp/includes/templating.ts": 0o644, + "templates/csharp/includes/tests.ts": 0o644, + "templates/csharp/includes/unimplemented.ts": 0o644, + "templates/csharp/includes/utils.ts": 0o644, + "templates/csharp/main.ts": 0o644, + "templates/csharp/method.cs.stmpl": 0o644, + "templates/csharp/mockserver.ts": 0o644, + "templates/csharp/model.cs.stmpl": 0o644, + "templates/csharp/modelfile.cs.stmpl": 0o644, + "templates/csharp/pagination.cs.stmpl": 0o644, + "templates/csharp/readme": 0o20000000755, + "templates/csharp/readme/errors.stmpl": 0o644, + "templates/csharp/readme/eventstream.stmpl": 0o644, + "templates/csharp/readme/http_client.stmpl": 0o644, + "templates/csharp/readme/installation.stmpl": 0o644, + "templates/csharp/readme/nuget.stmpl": 0o644, + "templates/csharp/readme/pagination.stmpl": 0o644, + "templates/csharp/readme/retries.stmpl": 0o644, + "templates/csharp/readme/security.stmpl": 0o644, + "templates/csharp/sdk.cs.stmpl": 0o644, + "templates/csharp/sdkconfig.cs.stmpl": 0o644, + "templates/csharp/servers.cs.stmpl": 0o644, + "templates/csharp/standalone.ts": 0o644, + "templates/csharp/subsdk.cs.stmpl": 0o644, + "templates/csharp/test.cs.stmpl": 0o644, + "templates/csharp/testfile.cs.stmpl": 0o644, + "templates/csharp/testhelper.cs.stmpl": 0o644, + "templates/csharp/testhelpers.cs.stmpl": 0o644, + "templates/csharp/testproject": 0o20000000755, + "templates/csharp/testproject/Program.cs.stmpl": 0o644, + "templates/csharp/testproject/testproject.csproj.stmpl": 0o644, + "templates/csharp/tests": 0o20000000755, + "templates/csharp/tests/Common": 0o20000000755, + "templates/csharp/tests/Common/CommonHelpers.cs.stmpl": 0o644, + "templates/csharp/tests/Common/Tests.csproj.stmpl": 0o644, + "templates/csharp/tests/basic-http": 0o20000000755, + "templates/csharp/tests/basic-http/AuthAdditionalTests.cs": 0o644, + "templates/csharp/tests/client-credentials": 0o20000000755, + "templates/csharp/tests/client-credentials-basic": 0o20000000755, + "templates/csharp/tests/client-credentials-basic/ClientCredentialsBasicHookShould.cs": 0o644, + "templates/csharp/tests/client-credentials/ClientCredentialsHookShould.cs": 0o644, + "templates/csharp/tests/custom-http": 0o20000000755, + "templates/csharp/tests/custom-http/AuthShould.cs": 0o644, + "templates/csharp/tests/primary": 0o20000000755, + "templates/csharp/tests/primary/AuthShould.cs": 0o644, + "templates/csharp/tests/primary/CancellationTokenShould.cs": 0o644, + "templates/csharp/tests/primary/CollectionsShould.cs": 0o644, + "templates/csharp/tests/primary/CustomClientShould.cs": 0o644, + "templates/csharp/tests/primary/ErrorsShould.cs": 0o644, + "templates/csharp/tests/primary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/FlatteningShould.cs": 0o644, + "templates/csharp/tests/primary/GlobalsAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/HeadersShould.cs": 0o644, + "templates/csharp/tests/primary/Helpers.cs": 0o644, + "templates/csharp/tests/primary/HooksShould.cs.stmpl": 0o644, + "templates/csharp/tests/primary/MultiLevelShould.cs": 0o644, + "templates/csharp/tests/primary/OpenEnumsShould.cs": 0o644, + "templates/csharp/tests/primary/OpenUnionsShould.cs": 0o644, + "templates/csharp/tests/primary/OptionalNullableFieldsShould.cs": 0o644, + "templates/csharp/tests/primary/PaginationShould.cs": 0o644, + "templates/csharp/tests/primary/ParametersShould.cs": 0o644, + "templates/csharp/tests/primary/RequestBodiesShould.cs": 0o644, + "templates/csharp/tests/primary/ResponseBodiesShould.cs": 0o644, + "templates/csharp/tests/primary/RetriesShould.cs": 0o644, + "templates/csharp/tests/primary/ServersShould.cs": 0o644, + "templates/csharp/tests/primary/SseStreamParserAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/TelemetryShould.cs.stmpl": 0o644, + "templates/csharp/tests/primary/UnionsShould.cs": 0o644, + "templates/csharp/tests/primary/testUpload.json": 0o644, + "templates/csharp/tests/quaternary": 0o20000000755, + "templates/csharp/tests/quaternary/AuthShould.cs": 0o644, + "templates/csharp/tests/quaternary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/quaternary/Helpers.cs": 0o644, + "templates/csharp/tests/quaternary/OpenEnumsShould.cs": 0o644, + "templates/csharp/tests/quaternary/OptionalNullableFieldsShould.cs": 0o644, + "templates/csharp/tests/quaternary/ParametersShould.cs": 0o644, + "templates/csharp/tests/quaternary/RequestBodiesShould.cs": 0o644, + "templates/csharp/tests/quaternary/ServersShould.cs": 0o644, + "templates/csharp/tests/quaternary/UnionsShould.cs": 0o644, + "templates/csharp/tests/secondary": 0o20000000755, + "templates/csharp/tests/secondary/AuthShould.cs": 0o644, + "templates/csharp/tests/secondary/HeadersShould.cs": 0o644, + "templates/csharp/tests/secondary/PaginationShould.cs": 0o644, + "templates/csharp/tests/secondary/ParametersShould.cs": 0o644, + "templates/csharp/tests/secondary/ResponseBodiesShould.cs": 0o644, + "templates/csharp/tests/secondary/ServersShould.cs": 0o644, + "templates/csharp/tests/security-options": 0o20000000755, + "templates/csharp/tests/security-options/AuthAdditionalShould.cs": 0o644, + "templates/csharp/tests/tertiary": 0o20000000755, + "templates/csharp/tests/tertiary/AuthShould.cs": 0o644, + "templates/csharp/tests/tertiary/CustomClientShould.cs": 0o644, + "templates/csharp/tests/tertiary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/tertiary/Helpers.cs": 0o644, + "templates/csharp/tests/tertiary/ParametersShould.cs": 0o644, + "templates/csharp/tests/tertiary/SchemaValidationShould.cs": 0o644, + "templates/csharp/tests/tertiary/ServersShould.cs": 0o644, + "templates/csharp/testusage": 0o20000000755, + "templates/csharp/testusage/testusage.csproj.stmpl": 0o644, + "templates/csharp/tsconfig.json": 0o644, + "templates/csharp/union.cs.stmpl": 0o644, + "templates/csharp/usage": 0o20000000755, + "templates/csharp/usage/block.stmpl": 0o644, + "templates/csharp/usage/call.stmpl": 0o644, + "templates/csharp/usage/errors.stmpl": 0o644, + "templates/csharp/usage/eventstream.stmpl": 0o644, + "templates/csharp/usage/init.stmpl": 0o644, + "templates/csharp/usage/model_snippet.stmpl": 0o644, + "templates/csharp/usage/pagination.stmpl": 0o644, + "templates/csharp/usage/result.stmpl": 0o644, + "templates/csharp/usage/retries.stmpl": 0o644, + "templates/csharp/usage/snippet.stmpl": 0o644, + "templates/csharp/usage/test.stmpl": 0o644, + "templates/go": 0o20000000755, + "templates/go/.gitignore.stmpl": 0o644, + "templates/go/auxiliary": 0o20000000755, + "templates/go/auxiliary/go.mod.stmpl": 0o644, + "templates/go/auxiliary/tests": 0o20000000755, + "templates/go/auxiliary/tests/files.go.stmpl": 0o644, + "templates/go/auxiliary/tests/helpers.go.stmpl": 0o644, + "templates/go/auxiliary/tests/testclient.go.stmpl": 0o644, + "templates/go/auxiliary/{{getErrorsLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getErrorsLocation}}/responsevalidationerror.go.stmpl": 0o644, + "templates/go/auxiliary/{{getErrorsLocation}}/{{getDefaultErrorFileName}}.go.stmpl": 0o644, + "templates/go/auxiliary/{{getNullableLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable.go.stmpl": 0o644, + "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable_test.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getPollingLocation}}/config.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}/errors.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}/option.go.stmpl": 0o644, + "templates/go/auxiliary/{{getRetryLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getRetryLocation}}/config.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/bigint.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/date.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/datetime.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/decimal.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/jsonl": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/jsonl/jsonl.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/pointers.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/stream": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getUtilsLocation}}/contenttype.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/env.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/form.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/headers.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/jq.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/json.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_credentials.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_sessions.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/pathparams.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/queryparams.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/requestbody.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/retries.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/security.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/union.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/union_test.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/utils.go.stmpl": 0o644, + "templates/go/config.ts": 0o644, + "templates/go/devcontainers": 0o20000000755, + "templates/go/devcontainers/config.stmpl": 0o644, + "templates/go/devcontainers/setup.stmpl": 0o644, + "templates/go/docs": 0o20000000755, + "templates/go/docs/types": 0o20000000755, + "templates/go/docs/types/date.stmpl": 0o644, + "templates/go/enum.go.stmpl": 0o644, + "templates/go/error.go.stmpl": 0o644, + "templates/go/examples.ts": 0o644, + "templates/go/exclusions.ts": 0o644, + "templates/go/features.ts": 0o644, + "templates/go/hooks": 0o20000000755, + "templates/go/hooks/clientcredentials.go.stmpl": 0o644, + "templates/go/hooks/hooks.go.stmpl": 0o644, + "templates/go/hooks/hooks.ts": 0o644, + "templates/go/hooks/oauth2_password.go.stmpl": 0o644, + "templates/go/hooks/oauth2scopes.go.stmpl": 0o644, + "templates/go/hooks/registration.go.stmpl": 0o644, + "templates/go/includes": 0o20000000755, + "templates/go/includes/dependencies.ts": 0o644, + "templates/go/includes/documentation.ts": 0o644, + "templates/go/includes/enums.ts": 0o644, + "templates/go/includes/errors.ts": 0o644, + "templates/go/includes/eventstreaming.ts": 0o644, + "templates/go/includes/imports.ts": 0o644, + "templates/go/includes/includes.ts": 0o644, + "templates/go/includes/modelusage.ts": 0o644, + "templates/go/includes/open-unions.ts": 0o644, + "templates/go/includes/operations.ts": 0o644, + "templates/go/includes/options.ts": 0o644, + "templates/go/includes/polling.ts": 0o644, + "templates/go/includes/responses.ts": 0o644, + "templates/go/includes/retractions.ts": 0o644, + "templates/go/includes/sanitization.ts": 0o644, + "templates/go/includes/serverusage.ts": 0o644, + "templates/go/includes/templating.ts": 0o644, + "templates/go/includes/tests.ts": 0o644, + "templates/go/includes/transform.ts": 0o644, + "templates/go/includes/unions.ts": 0o644, + "templates/go/includes/utils.ts": 0o644, + "templates/go/main.ts": 0o644, + "templates/go/method.go.stmpl": 0o644, + "templates/go/method_options.go.stmpl": 0o644, + "templates/go/method_options_setup.go.stmpl": 0o644, + "templates/go/mockserver.ts": 0o644, + "templates/go/model.go.stmpl": 0o644, + "templates/go/modelfile.go.stmpl": 0o644, + "templates/go/options.go.stmpl": 0o644, + "templates/go/pagination.go.stmpl": 0o644, + "templates/go/readme": 0o20000000755, + "templates/go/readme/errors.stmpl": 0o644, + "templates/go/readme/eventstream.stmpl": 0o644, + "templates/go/readme/http_client.stmpl": 0o644, + "templates/go/readme/installation.stmpl": 0o644, + "templates/go/readme/jsonl.stmpl": 0o644, + "templates/go/readme/options.stmpl": 0o644, + "templates/go/readme/pagination.stmpl": 0o644, + "templates/go/readme/retries.stmpl": 0o644, + "templates/go/readme/security.stmpl": 0o644, + "templates/go/readme/types.stmpl": 0o644, + "templates/go/sdk.go.stmpl": 0o644, + "templates/go/sdkconfiguration.go.stmpl": 0o644, + "templates/go/standalone.ts": 0o644, + "templates/go/subsdk.go.stmpl": 0o644, + "templates/go/test.go.stmpl": 0o644, + "templates/go/testfile.go.stmpl": 0o644, + "templates/go/testhelper.go.stmpl": 0o644, + "templates/go/testhelpers.go.stmpl": 0o644, + "templates/go/testproject": 0o20000000755, + "templates/go/testproject/go.mod.stmpl": 0o644, + "templates/go/tests": 0o20000000755, + "templates/go/tests/basic-http": 0o20000000755, + "templates/go/tests/basic-http/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/client-credentials": 0o20000000755, + "templates/go/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, + "templates/go/tests/common": 0o20000000755, + "templates/go/tests/common/common_helpers_test.go": 0o644, + "templates/go/tests/custom-http": 0o20000000755, + "templates/go/tests/custom-http/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/oauth2-password": 0o20000000755, + "templates/go/tests/oauth2-password/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary": 0o20000000755, + "templates/go/tests/primary/allow_reserved_query_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/collections_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/custom_code_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/custom_omitempty_vs_builtin_test.go.stmpl": 0o644, + "templates/go/tests/primary/customclient_test.go.stmpl": 0o644, + "templates/go/tests/primary/enums_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/errors_test.go.stmpl": 0o644, + "templates/go/tests/primary/eventstreaming_test.go.stmpl": 0o644, + "templates/go/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/go/tests/primary/globals_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/headers_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/helpers_test.go.stmpl": 0o644, + "templates/go/tests/primary/hooks_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/jsonlstreaming_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/open_unions_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/optional_field_serialization_test.go.stmpl": 0o644, + "templates/go/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/pagination_test.go.stmpl": 0o644, + "templates/go/tests/primary/parameter_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/polling_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/retries_test.go.stmpl": 0o644, + "templates/go/tests/primary/security_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/servers_test.go.stmpl": 0o644, + "templates/go/tests/primary/telemetry_test.go.stmpl": 0o644, + "templates/go/tests/primary/testdata": 0o20000000755, + "templates/go/tests/primary/testdata/testUpload.json": 0o644, + "templates/go/tests/primary/unions_additional_test.go.stmpl": 0o644, + "templates/go/tests/quaternary": 0o20000000755, + "templates/go/tests/quaternary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/quaternary/servers_test.go.stmpl": 0o644, + "templates/go/tests/quaternary/transform_test.go.stmpl": 0o644, + "templates/go/tests/secondary": 0o20000000755, + "templates/go/tests/secondary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/headers_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/json_omitempty_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/jsonlstreaming_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/pagination_test.go.stmpl": 0o644, + "templates/go/tests/secondary/redirects_followed_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/servers_test.go.stmpl": 0o644, + "templates/go/tests/security-options": 0o20000000755, + "templates/go/tests/security-options/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary": 0o20000000755, + "templates/go/tests/tertiary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/servers_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/unions_generic_additional_test.go.stmpl": 0o644, + "templates/go/testusage": 0o20000000755, + "templates/go/testusage/go.mod.stmpl": 0o644, + "templates/go/tsconfig.json": 0o644, + "templates/go/union.go.stmpl": 0o644, + "templates/go/usage": 0o20000000755, + "templates/go/usage/block.stmpl": 0o644, + "templates/go/usage/call.stmpl": 0o644, + "templates/go/usage/eventstream.stmpl": 0o644, + "templates/go/usage/init.stmpl": 0o644, + "templates/go/usage/jsonl.stmpl": 0o644, + "templates/go/usage/model_snippet.stmpl": 0o644, + "templates/go/usage/pagination.stmpl": 0o644, + "templates/go/usage/result.stmpl": 0o644, + "templates/go/usage/retries.stmpl": 0o644, + "templates/go/usage/snippet.stmpl": 0o644, + "templates/go/usage/test.stmpl": 0o644, + "templates/java": 0o20000000755, + "templates/java/SUNSET": 0o644, + "templates/java/config.ts": 0o644, + "templates/javav2": 0o20000000755, + "templates/javav2/.gitignore.stmpl": 0o644, + "templates/javav2/ClientCredentialsHook.java.stmpl": 0o644, + "templates/javav2/HIDDEN": 0o644, + "templates/javav2/OAuth2PasswordHook.java.stmpl": 0o644, + "templates/javav2/OAuth2Scopes.java.stmpl": 0o644, + "templates/javav2/SDKHooks.java.stmpl": 0o644, + "templates/javav2/async-method.java.stmpl": 0o644, + "templates/javav2/async-sdk.java.stmpl": 0o644, + "templates/javav2/async-subsdk.java.stmpl": 0o644, + "templates/javav2/auxiliary": 0o20000000755, + "templates/javav2/auxiliary/build.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/docs": 0o20000000755, + "templates/javav2/auxiliary/docs/utils": 0o20000000755, + "templates/javav2/auxiliary/docs/utils/Blob.md.stmpl": 0o644, + "templates/javav2/auxiliary/gradle": 0o20000000755, + "templates/javav2/auxiliary/gradle.properties.stmpl": 0o644, + "templates/javav2/auxiliary/gradle/wrapper": 0o20000000755, + "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.jar": 0o644, + "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.properties": 0o644, + "templates/javav2/auxiliary/gradlew": 0o755, + "templates/javav2/auxiliary/gradlew.bat": 0o755, + "templates/javav2/auxiliary/publishing.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/settings.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/spring": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/build.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java": 0o20000000755, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}": 0o20000000755, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}/ConditionalOnPropertyPrefix.java.stmpl": 0o644, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}/OnPropertyPrefixCondition.java.stmpl": 0o644, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 02c9cc68a..7e26522b5 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -169,7 +169,9 @@ The generator follows a **generated commands + shared runtime** pattern: │ keyring.go ─ OS keychain integration (go-keyring) │ │ interactive/ │ │ policy.go ─ Shared prompt/form/explorer policy │ -│ interactive.go ─ Missing-flag prompting via huh forms │ +│ interactive.go ─ Missing-flag prompting via forms │ +│ forms/ │ +│ *.go ─ Input/select/confirm terminal forms │ │ explorer/ │ │ *.go ─ Interactive command explorer TUI │ │ testclient/ │ @@ -759,7 +761,7 @@ cli users create --no-interactive - `interactive.Intercept` is installed after generated intents, both custom-command registration surfaces, Cobra's default help/completion commands, and declared command ordering. `usage.Intercept` is installed immediately afterward as the outer wrapper; `--usage`, `--schema`, and any other flag annotated as a documentation surface never open a prompt. - `interactive.Resolve` is the single runtime gate for required-input prompts, `configure` / `auth login` forms, and direct explorer validation. `interactive.IsInteractive` remains a compatibility wrapper for hand-written commands. The compatibility-only bare-root auto-launch retains `shouldAutoExplore` so its process-entry behavior is unchanged. `interactiveByDefault` selects the generated default of `--interactive`; `--no-interactive` and active agent mode suppress prompts and forms. -- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and huh's accessible line prompts off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering. +- Required-input prompting needs both stdin and stdout to be TTYs unless a context-injected test prompter supplies terminal capability. Guided forms use the rich TUI on a terminal pair and the accessible line prompts of `internal/forms` off-TTY. Consequently, compatibility builds (`interactiveByDefault: true`) retain their existing off-TTY accessible forms, while non-interactive-by-default builds never read stdin for a form off-TTY unless `--interactive` is passed. `configure` and `auth login` resolve through `Decision.SetupFormMode`: run bare on a terminal pair they open the form regardless of `interactiveByDefault`, unless `--no-interactive`, `--interactive=false`, or agent mode is active. `configure --default-output-format` stores the default output format without a form; `--output-format` keeps controlling only the current command's rendering. - Direct `explore` is itself an explicit request, but it rejects `--no-interactive` / `--interactive=false` as `CLI_VALIDATION` (`validation_error`, exit 2) and requires both stdin and stdout to be TTYs before Bubble Tea is called. A missing terminal remains a runtime error (exit 1). Agent mode retains the existing behavior of showing root help instead. - Interception is additive. When prompting is suppressed or no required promptable input is missing, the command keeps its existing behavior exactly: operation request builders report missing flags, bare required-input intents return typed usage help errors, and hand-written commands retain their own errors/help/exit codes. A `speakeasy:prompt-direct` field participates only after a missing required input has already triggered a form. - Required flags are prompted when unresolved from **all** declared sources: an explicit/default-resolving flag, environment variable, config value, or changed whole-body flag for a body-derived field. Resolution checks are pure; environment and config values remain for the request builder to apply with its existing precedence. Piped stdin is deliberately not part of this check because a pipe makes the session non-interactive. @@ -810,7 +812,7 @@ _ = interactive.Declare(cmd, interactive.CommandSpec{Args: []interactive.ArgSpec Tests should use `interactive.WithPrompter(ctx, p)` (or the generated harness's `RunInteractive(args, p)`). The injected prompter substitutes only for the TTY check; it does not bypass a false `--interactive` value, `--no-interactive`, agent mode, usage, or documentation surfaces. The seam exposes stable field IDs (`arg:`, `flag:`), field order, applied values, and exported `PromptField.Direct` without driving Bubble Tea through a pipe. -**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, huh, bubbles) are the `charm.land/*/v2` modules. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears. +**Startup cost**: the interactive packages (Bubble Tea, Lip Gloss, bubbles) are the `charm.land/*/v2` modules, and `internal/forms` builds on them. Unlike v1, they never query the terminal at package initialization, so every command starts without emitting background-color or cursor-position requests, including on PTYs that never answer. `tests/startup_tty_test.go` runs `version` on an unanswered pseudo-terminal and fails if a query or a stall reappears. **Generator config knobs**: diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index 736dd67b6..7d7dfd132 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -6,7 +6,7 @@ {{- addImport "fmt" -}} {{- addImport "os" -}} {{- addImport "golang.org/x/term" -}} -{{- addImport "charm.land/huh/v2" -}} +{{- addImport "internal/forms" true -}} {{- addImport "charm.land/lipgloss/v2" -}} package cli @@ -84,7 +84,7 @@ This removes all credentials previously set via auth login or configure.`, return nil } -// runAuthLoginCmd executes the auth login command using huh forms. +// runAuthLoginCmd executes the auth login command using guided forms. func runAuthLoginCmd(cmd *cobra.Command, args []string) error { if dryRunLocalNoop(cmd, "auth login changes local credentials only (no API request); nothing was changed.") { return nil @@ -150,18 +150,17 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { } // authFormTheme builds the form theme for auth login. -func authFormTheme() huh.Theme { - return huh.ThemeFunc(authFormStyles) +func authFormTheme() forms.Theme { + return forms.ThemeFunc(authFormStyles) } -func authFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func authFormStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := {{themeAccent}} dimmed := {{themeDimmed}} subtle := {{themeSubtle}} errColor := {{themeError}} - greenColor := {{themeSuccess}} t.Focused.Base = t.Focused.Base. BorderLeft(true). @@ -174,11 +173,8 @@ func authFormStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) @@ -195,13 +191,11 @@ func authFormStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } -// authFormWidth returns the terminal width for sizing huh forms. +// authFormWidth returns the terminal width for sizing forms. func authFormWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { diff --git a/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl new file mode 100644 index 000000000..e774f8237 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl @@ -0,0 +1,182 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "bufio" + "cmp" + "errors" + "fmt" + "io" + "slices" + "strconv" + "strings" + + "golang.org/x/term" +) + +func atoi(s string) (int, error) { + if strings.TrimSpace(s) == "" { + return -1, nil + } + return strconv.Atoi(s) +} + +// promptInt prompts a user for an integer between a certain range. +// +// Given invalid input (non-integers, integers outside of the range), the user +// will continue to be reprompted until a valid input is given, ensuring that +// the return value is always valid. +func promptInt( + out io.Writer, + in io.Reader, + prompt string, + low, high int, + defaultValue *int, +) int { + var choice int + + validInt := func(s string) error { + if strings.TrimSpace(s) == "" && defaultValue != nil { + return nil + } + i, err := atoi(s) + if err != nil || i < low || i > high { + if low == high { + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be %d", low) + } + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) + } + return nil + } + + input := promptString( + out, + in, + prompt, + ptrToStr(defaultValue, strconv.Itoa), + validInt, + ) + choice, _ = strconv.Atoi(input) + return choice +} + +func parseBool(s string) (bool, error) { + s = strings.ToLower(s) + + if slices.Contains([]string{"y", "yes"}, s) { + return true, nil + } + + // As a special case, we default to "" to no since the usage of this + // function suggests N is the default. + if slices.Contains([]string{"n", "no"}, s) { + return false, nil + } + + return false, errors.New("invalid input. please try again") +} + +// promptBool prompts a user for a boolean value. +// +// Given invalid input (non-boolean), the user will continue to be reprompted +// until a valid input is given, ensuring that the return value is always valid. +func promptBool( + out io.Writer, + in io.Reader, + prompt string, + defaultValue bool, +) bool { + validBool := func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + _, err := parseBool(s) + return err + } + + input := promptString( + out, in, prompt, + boolToStr(defaultValue), + validBool, + ) + b, _ := parseBool(input) + return b +} + +// promptPassword allows to prompt for a password. +// In must be the fd of a tty. +func promptPassword( + out io.Writer, + in uintptr, + prompt string, + validator func(input string) error, +) (string, error) { + for { + _, _ = fmt.Fprint(out, prompt) + pwd, err := term.ReadPassword(int(in)) + if err != nil { + return "", err + } + _, _ = fmt.Fprintln(out) + if err := validator(string(pwd)); err != nil { + _, _ = fmt.Fprintln(out, err) + continue + } + return string(pwd), nil + } +} + +// promptString prompts a user for a string value and validates it against a +// validator function. It re-prompts the user until a valid input is given. +func promptString( + out io.Writer, + in io.Reader, + prompt string, + defaultValue string, + validator func(input string) error, +) string { + scanner := bufio.NewScanner(in) + + var ( + valid bool + input string + ) + + for !valid { + _, _ = fmt.Fprint(out, prompt) + if !scanner.Scan() { + // no way to bubble up errors or signal cancellation + // but the program is probably not continuing if + // stdin sent EOF + _, _ = fmt.Fprintln(out) + break + } + input = scanner.Text() + + if err := validator(input); err != nil { + _, _ = fmt.Fprintln(out, err) + continue + } + + break + } + + return cmp.Or(strings.TrimSpace(input), defaultValue) +} + +func ptrToStr[T any](t *T, fn func(t T) string) string { + if t == nil { + return "" + } + return fn(*t) +} + +func boolToStr(b bool) string { + if b { + return "y" + } + return "N" +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl new file mode 100644 index 000000000..899b422e1 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl @@ -0,0 +1,46 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +// Accessor give read/write access to field values. +type Accessor[T any] interface { + Get() T + Set(value T) +} + +// EmbeddedAccessor is a basic accessor, acting as the default one for fields. +type EmbeddedAccessor[T any] struct { + value T +} + +// Get gets the value. +func (a *EmbeddedAccessor[T]) Get() T { + return a.value +} + +// Set sets the value. +func (a *EmbeddedAccessor[T]) Set(value T) { + a.value = value +} + +// PointerAccessor allows field value to be exposed as a pointed variable. +type PointerAccessor[T any] struct { + value *T +} + +// NewPointerAccessor returns a new pointer accessor. +func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { + return &PointerAccessor[T]{ + value: value, + } +} + +// Get gets the value. +func (a *PointerAccessor[T]) Get() T { + return *a.value +} + +// Set sets the value. +func (a *PointerAccessor[T]) Set(value T) { + *a.value = value +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl new file mode 100644 index 000000000..fbf4f06eb --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl @@ -0,0 +1,31 @@ +{{- if isAnyInteractiveEnabled}} +// MIT License +// +// Copyright (c) 2023–2026 Charm +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Package forms renders the terminal forms used by guided prompts: text +// inputs, multi-line text, selects, and confirmations, with an accessible +// line-prompt mode for screen readers and non-TTY input. +// +// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), +// reduced to the fields and form options this CLI uses. +package forms +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl new file mode 100644 index 000000000..5e3564903 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl @@ -0,0 +1,308 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "cmp" + "io" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Confirm is a form confirm field. +type Confirm struct { + accessor Accessor[bool] + + // customization + title string + description string + affirmative string + negative string + + // error handling + validate func(bool) error + err error + + // state + focused bool + + // options + width int + height int + inline bool + theme Theme + hasDarkBg bool + keymap ConfirmKeyMap + buttonAlignment lipgloss.Position +} + +// NewConfirm returns a new confirm field. +func NewConfirm() *Confirm { + return &Confirm{ + accessor: &EmbeddedAccessor[bool]{}, + affirmative: "Yes", + negative: "No", + validate: func(bool) error { return nil }, + buttonAlignment: lipgloss.Center, + } +} + +// Validate sets the validation function of the confirm field. +func (c *Confirm) Validate(validate func(bool) error) *Confirm { + c.validate = validate + return c +} + +// Error returns the error of the confirm field. +func (c *Confirm) Error() error { + return c.err +} + +// Skip returns whether the confirm should be skipped or should be blocking. +func (*Confirm) Skip() bool { + return false +} + +// Zoom returns whether the input should be zoomed. +func (*Confirm) Zoom() bool { + return false +} + +// Affirmative sets the affirmative value of the confirm field. +func (c *Confirm) Affirmative(affirmative string) *Confirm { + c.affirmative = affirmative + return c +} + +// Negative sets the negative value of the confirm field. +func (c *Confirm) Negative(negative string) *Confirm { + c.negative = negative + return c +} + +// Value sets the value of the confirm field. +func (c *Confirm) Value(value *bool) *Confirm { + return c.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the confirm field. +func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { + c.accessor = accessor + return c +} + +// Title sets the title of the confirm field. +func (c *Confirm) Title(title string) *Confirm { + c.title = title + return c +} + +// Description sets the description of the confirm field. +func (c *Confirm) Description(description string) *Confirm { + c.description = description + return c +} + +// Inline sets whether the field should be inline. +func (c *Confirm) Inline(inline bool) *Confirm { + c.inline = inline + return c +} + +// Focus focuses the confirm field. +func (c *Confirm) Focus() tea.Cmd { + c.focused = true + return nil +} + +// Blur blurs the confirm field. +func (c *Confirm) Blur() tea.Cmd { + c.focused = false + c.err = c.validate(c.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the confirm field. +func (c *Confirm) KeyBinds() []key.Binding { + return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} +} + +// Init initializes the confirm field. +func (c *Confirm) Init() tea.Cmd { + return nil +} + +// Update updates the confirm field. +func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + c.hasDarkBg = msg.IsDark() + case tea.KeyPressMsg: + c.err = nil + switch { + case key.Matches(msg, c.keymap.Toggle): + if c.negative == "" { + break + } + c.accessor.Set(!c.accessor.Get()) + case key.Matches(msg, c.keymap.Prev): + cmds = append(cmds, PrevField) + case key.Matches(msg, c.keymap.Next, c.keymap.Submit): + cmds = append(cmds, NextField) + case key.Matches(msg, c.keymap.Accept): + c.accessor.Set(true) + cmds = append(cmds, NextField) + case key.Matches(msg, c.keymap.Reject): + c.accessor.Set(false) + cmds = append(cmds, NextField) + } + } + + return c, tea.Batch(cmds...) +} + +func (c *Confirm) activeStyles() *FieldStyles { + theme := c.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if c.focused { + return &theme.Theme(c.hasDarkBg).Focused + } + return &theme.Theme(c.hasDarkBg).Blurred +} + +// View renders the confirm field. +func (c *Confirm) View() string { + styles := c.activeStyles() + maxWidth := c.width - styles.Base.GetHorizontalFrameSize() + + var wroteHeader bool + var sb strings.Builder + if c.title != "" { + sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) + wroteHeader = true + } + if c.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + wroteHeader = true + } + + if c.description != "" { + description := styles.Description.Render(wrap(c.description, maxWidth)) + if !c.inline && c.description != "" { + sb.WriteString("\n") + } + sb.WriteString(description) + wroteHeader = true + } + + if !c.inline && wroteHeader { + sb.WriteString("\n") + sb.WriteString("\n") + } + + var negative string + var affirmative string + if c.negative != "" { + if c.accessor.Get() { + affirmative = styles.FocusedButton.Render(c.affirmative) + negative = styles.BlurredButton.Render(c.negative) + } else { + affirmative = styles.BlurredButton.Render(c.affirmative) + negative = styles.FocusedButton.Render(c.negative) + } + c.keymap.Reject.SetHelp("n", c.negative) + } else { + affirmative = styles.FocusedButton.Render(c.affirmative) + c.keymap.Reject.SetEnabled(false) + } + + c.keymap.Accept.SetHelp("y", c.affirmative) + + buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) + + promptWidth := lipgloss.Width(sb.String()) + buttonsWidth := lipgloss.Width(buttonsRow) + + renderWidth := max(buttonsWidth, promptWidth) + + style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) + + sb.WriteString(style.Render(buttonsRow)) + return styles.Base.Width(c.width).Height(c.height). + Render(sb.String()) +} + +// RunAccessible runs the confirm field in accessible mode. +func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { + styles := c.activeStyles() + defaultValue := c.GetValue().(bool) + opts := "[y/N]" + if defaultValue { + opts = "[Y/n]" + } + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(c.title, "Choose"), opts) + c.accessor.Set(promptBool(w, r, prompt, defaultValue)) + return nil +} + +func (c *Confirm) String() string { + if c.accessor.Get() { + return c.affirmative + } + return c.negative +} + +// WithTheme sets the theme of the confirm field. +func (c *Confirm) WithTheme(theme Theme) Field { + if c.theme != nil { + return c + } + c.theme = theme + return c +} + +// WithKeyMap sets the keymap of the confirm field. +func (c *Confirm) WithKeyMap(k *KeyMap) Field { + c.keymap = k.Confirm + return c +} + +// WithWidth sets the width of the confirm field. +func (c *Confirm) WithWidth(width int) Field { + c.width = width + return c +} + +// WithHeight sets the height of the confirm field. +func (c *Confirm) WithHeight(height int) Field { + c.height = height + return c +} + +// WithPosition sets the position of the confirm field. +func (c *Confirm) WithPosition(p FieldPosition) Field { + c.keymap.Prev.SetEnabled(!p.IsFirst()) + c.keymap.Next.SetEnabled(!p.IsLast()) + c.keymap.Submit.SetEnabled(p.IsLast()) + return c +} + +// WithButtonAlignment sets the button position of the confirm field. +func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { + c.buttonAlignment = p + return c +} + +// GetValue returns the value of the field. +func (c *Confirm) GetValue() any { + return c.accessor.Get() +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl new file mode 100644 index 000000000..085f925e0 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl @@ -0,0 +1,345 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "cmp" + "errors" + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Input is a input field. +// +// The input field is a field that allows the user to enter text. Use it to user +// input. It can be used for collecting text, passwords, or other short input. +// +// The input field supports Suggestions, Placeholder, and Validation. +type Input struct { + accessor Accessor[string] + + title string + description string + + textinput textinput.Model + + inline bool + validate func(string) error + err error + focused bool + + width int + height int + + theme Theme + hasDarkBg bool + keymap InputKeyMap +} + +// NewInput creates a new input field. +// +// The input field is a field that allows the user to enter text. Use it to user +// input. It can be used for collecting text, passwords, or other short input. +// +// The input field supports Suggestions, Placeholder, and Validation. +func NewInput() *Input { + input := textinput.New() + + i := &Input{ + accessor: &EmbeddedAccessor[string]{}, + textinput: input, + validate: func(string) error { return nil }, + } + + return i +} + +// Value sets the value of the input field. +func (i *Input) Value(value *string) *Input { + return i.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the input field. +func (i *Input) Accessor(accessor Accessor[string]) *Input { + i.accessor = accessor + i.textinput.SetValue(i.accessor.Get()) + return i +} + +// Title sets the title of the input field. +func (i *Input) Title(title string) *Input { + i.title = title + return i +} + +// Description sets the description of the input field. +func (i *Input) Description(description string) *Input { + i.description = description + return i +} + +// Prompt sets the prompt of the input field. +func (i *Input) Prompt(prompt string) *Input { + i.textinput.Prompt = prompt + return i +} + +// CharLimit sets the character limit of the input field. +func (i *Input) CharLimit(charlimit int) *Input { + i.textinput.CharLimit = charlimit + return i +} + +// EchoMode sets the input behavior of the text Input field. +type EchoMode textinput.EchoMode + +const ( + // EchoModeNormal displays text as is. + // This is the default behavior. + EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal) + + // EchoModePassword displays the EchoCharacter mask instead of actual characters. + // This is commonly used for password fields. + EchoModePassword EchoMode = EchoMode(textinput.EchoPassword) + + // EchoModeNone displays nothing as characters are entered. + // This is commonly seen for password fields on the command line. + EchoModeNone EchoMode = EchoMode(textinput.EchoNone) +) + +// EchoMode sets the echo mode of the input. +func (i *Input) EchoMode(mode EchoMode) *Input { + i.textinput.EchoMode = textinput.EchoMode(mode) + return i +} + +// Placeholder sets the placeholder of the text input. +func (i *Input) Placeholder(str string) *Input { + i.textinput.Placeholder = str + return i +} + +// Inline sets whether the title and input should be on the same line. +func (i *Input) Inline(inline bool) *Input { + i.inline = inline + return i +} + +// Validate sets the validation function of the input field. +func (i *Input) Validate(validate func(string) error) *Input { + i.validate = validate + return i +} + +// Error returns the error of the input field. +func (i *Input) Error() error { return i.err } + +// Skip returns whether the input should be skipped or should be blocking. +func (*Input) Skip() bool { return false } + +// Zoom returns whether the input should be zoomed. +func (*Input) Zoom() bool { return false } + +// Focus focuses the input field. +func (i *Input) Focus() tea.Cmd { + i.focused = true + return i.textinput.Focus() +} + +// Blur blurs the input field. +func (i *Input) Blur() tea.Cmd { + i.focused = false + i.accessor.Set(i.textinput.Value()) + i.textinput.Blur() + i.err = i.validate(i.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the input field. +func (i *Input) KeyBinds() []key.Binding { + if i.textinput.ShowSuggestions { + return []key.Binding{i.keymap.AcceptSuggestion, i.keymap.Prev, i.keymap.Submit, i.keymap.Next} + } + return []key.Binding{i.keymap.Prev, i.keymap.Submit, i.keymap.Next} +} + +// Init initializes the input field. +func (i *Input) Init() tea.Cmd { + i.textinput.Blur() + return nil +} + +// Update updates the input field. +func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + i.hasDarkBg = msg.IsDark() + case updateFieldMsg: + return i, nil + case tea.KeyPressMsg: + i.err = nil + + switch { + case key.Matches(msg, i.keymap.Prev): + cmds = append(cmds, PrevField) + case key.Matches(msg, i.keymap.Next, i.keymap.Submit): + value := i.textinput.Value() + i.err = i.validate(value) + if i.err != nil { + return i, nil + } + cmds = append(cmds, NextField) + } + } + + i.textinput, cmd = i.textinput.Update(msg) + cmds = append(cmds, cmd) + i.accessor.Set(i.textinput.Value()) + + return i, tea.Batch(cmds...) +} + +func (i *Input) activeStyles() *FieldStyles { + theme := i.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if i.focused { + return &theme.Theme(i.hasDarkBg).Focused + } + return &theme.Theme(i.hasDarkBg).Blurred +} + +// View renders the input field. +func (i *Input) View() string { + styles := i.activeStyles() + maxWidth := i.width - styles.Base.GetHorizontalFrameSize() + + // NB: since the method is on a pointer receiver these are being mutated. + // Because this runs on every render this shouldn't matter in practice, + // however. + st := i.textinput.Styles() + st.Cursor.Color = styles.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.Text = styles.TextInput.Text + st.Focused.Placeholder = styles.TextInput.Placeholder + i.textinput.SetStyles(st) + + // Adjust text input size to its char limit if it fit in its width + if i.textinput.CharLimit > 0 { + i.textinput.SetWidth(max(min(i.textinput.CharLimit, i.textinput.Width(), maxWidth), 0)) + } + + var sb strings.Builder + if i.title != "" { + sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth))) + if !i.inline { + sb.WriteString("\n") + } + } + if i.description != "" { + sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth))) + if !i.inline { + sb.WriteString("\n") + } + } + sb.WriteString(i.textinput.View()) + + return styles.Base. + Width(i.width). + Height(i.height). + Render(sb.String()) +} + +// RunAccessible runs the input field in accessible mode. +func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { + styles := i.activeStyles() + validator := func(input string) error { + if i.textinput.CharLimit > 0 && len(input) > i.textinput.CharLimit { + return fmt.Errorf("Input cannot exceed %d characters", i.textinput.CharLimit) + } + return i.validate(input) + } + + switch i.textinput.EchoMode { + case textinput.EchoNormal: + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(i.title, "Input:")) + value := promptString(w, r, prompt, i.GetValue().(string), validator) + i.accessor.Set(value) + return nil + default: + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(i.title, "Password:")) + if fd, ok := r.(interface{ Fd() uintptr }); ok { + value, err := promptPassword(w, fd.Fd(), prompt, validator) + if err != nil { + return err + } + i.accessor.Set(value) + return nil + } + return errors.New("password asking needs a tty") + } +} + +// WithKeyMap sets the keymap on an input field. +func (i *Input) WithKeyMap(k *KeyMap) Field { + i.keymap = k.Input + i.textinput.KeyMap.AcceptSuggestion = i.keymap.AcceptSuggestion + return i +} + +// WithTheme sets the theme of the input field. +func (i *Input) WithTheme(theme Theme) Field { + if i.theme != nil { + return i + } + i.theme = theme + return i +} + +// WithWidth sets the width of the input field. +func (i *Input) WithWidth(width int) Field { + styles := i.activeStyles() + i.width = width + frameSize := styles.Base.GetHorizontalFrameSize() + promptWidth := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) + titleWidth := lipgloss.Width(styles.Title.Render(i.title)) + descriptionWidth := lipgloss.Width(styles.Description.Render(i.description)) + i.textinput.SetWidth(width - frameSize - promptWidth - 1) + if i.inline { + i.textinput.SetWidth(i.textinput.Width() - titleWidth - descriptionWidth) + } + return i +} + +// WithHeight sets the height of the input field. +func (i *Input) WithHeight(height int) Field { + i.height = height + return i +} + +// WithPosition sets the position of the input field. +func (i *Input) WithPosition(p FieldPosition) Field { + i.keymap.Prev.SetEnabled(!p.IsFirst()) + i.keymap.Next.SetEnabled(!p.IsLast()) + i.keymap.Submit.SetEnabled(p.IsLast()) + return i +} + +// GetValue returns the value of the field. +func (i *Input) GetValue() any { + return i.accessor.Get() +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl new file mode 100644 index 000000000..318b914e9 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl @@ -0,0 +1,680 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "cmp" + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +const ( + minHeight = 1 + defaultHeight = 10 +) + +// Select is a select field. +// +// A select field is a field that allows the user to select from a list of +// options. The options can be provided statically or dynamically using Options +// or OptionsFunc. The options can be filtered using "/" and navigation is done +// using j/k, up/down, or ctrl+n/ctrl+p keys. +type Select[T comparable] struct { + accessor Accessor[T] + + viewport viewport.Model + + title string + description string + options []Option[T] + filteredOptions []Option[T] + + validate func(T) error + err error + + selected int + focused bool + filtering bool + filter textinput.Model + + inline bool + width int + height int + theme Theme + hasDarkBg bool + keymap SelectKeyMap +} + +// NewSelect creates a new select field. +// +// A select field is a field that allows the user to select from a list of +// options. The options can be provided statically or dynamically using Options +// or OptionsFunc. The options can be filtered using "/" and navigation is done +// using j/k, up/down, or ctrl+n/ctrl+p keys. +func NewSelect[T comparable]() *Select[T] { + filter := textinput.New() + filter.Prompt = "/" + + return &Select[T]{ + accessor: &EmbeddedAccessor[T]{}, + validate: func(T) error { return nil }, + filtering: false, + filter: filter, + } +} + +// Value sets the value of the select field. +func (s *Select[T]) Value(value *T) *Select[T] { + return s.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the select field. +func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] { + s.accessor = accessor + s.selectValue(s.accessor.Get()) + s.updateValue() + return s +} + +func (s *Select[T]) selectValue(value T) { + for i, o := range s.options { + if o.Value == value { + s.selected = i + break + } + } +} + +// Title sets the title of the select field. +func (s *Select[T]) Title(title string) *Select[T] { + s.title = title + return s +} + +// Filtering sets the filtering state of the select field. +func (s *Select[T]) Filtering(filtering bool) *Select[T] { + s.filtering = filtering + s.filter.Focus() + return s +} + +// Description sets the description of the select field. +func (s *Select[T]) Description(description string) *Select[T] { + s.description = description + return s +} + +// Options sets the options of the select field. +// +// This is what your user will select from. +// +// Title +// Description +// +// -> Option 1 +// Option 2 +// Option 3 +func (s *Select[T]) Options(options ...Option[T]) *Select[T] { + if len(options) <= 0 { + return s + } + s.options = options + s.filteredOptions = options + + s.selectOption() + + s.updateViewportSize() + s.updateValue() + + return s +} + +func (s *Select[T]) selectOption() { + // Set the cursor to the existing value. + for i, option := range s.options { + if option.Value == s.accessor.Get() { + s.selected = i + break + } + } + s.ensureCursorVisible() +} + +// Inline sets whether the select input should be inline. +func (s *Select[T]) Inline(v bool) *Select[T] { + s.inline = v + if v { + s.Height(1) + } + s.keymap.Left.SetEnabled(v) + s.keymap.Right.SetEnabled(v) + s.keymap.Up.SetEnabled(!v) + s.keymap.Down.SetEnabled(!v) + return s +} + +// Height sets the height of the select field. If the number of options exceeds +// the height, the select field will become scrollable. +func (s *Select[T]) Height(height int) *Select[T] { + s.height = height + s.updateViewportSize() + return s +} + +// Validate sets the validation function of the select field. +func (s *Select[T]) Validate(validate func(T) error) *Select[T] { + s.validate = validate + return s +} + +// Error returns the error of the select field. +func (s *Select[T]) Error() error { return s.err } + +// Skip returns whether the select should be skipped or should be blocking. +func (*Select[T]) Skip() bool { return false } + +// Zoom returns whether the input should be zoomed. +func (*Select[T]) Zoom() bool { return false } + +// Focus focuses the select field. +func (s *Select[T]) Focus() tea.Cmd { + s.focused = true + return nil +} + +// Blur blurs the select field. +func (s *Select[T]) Blur() tea.Cmd { + value := s.accessor.Get() + if s.inline { + s.clearFilter() + s.selectValue(value) + } + s.focused = false + s.err = s.validate(value) + return nil +} + +// Hovered returns the value of the option under the cursor, and a bool +// indicating whether one was found. If there are no visible options, returns +// a zero-valued T and false. +func (s *Select[T]) Hovered() (T, bool) { + if len(s.filteredOptions) == 0 || s.selected >= len(s.filteredOptions) { + var zero T + return zero, false + } + return s.filteredOptions[s.selected].Value, true +} + +// KeyBinds returns the help keybindings for the select field. +func (s *Select[T]) KeyBinds() []key.Binding { + return []key.Binding{ + s.keymap.Up, + s.keymap.Down, + s.keymap.Left, + s.keymap.Right, + s.keymap.Filter, + s.keymap.SetFilter, + s.keymap.ClearFilter, + s.keymap.Prev, + s.keymap.Next, + s.keymap.Submit, + } +} + +// Init initializes the select field. +func (s *Select[T]) Init() tea.Cmd { + return nil +} + +// Update updates the select field. +func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) { + s.updateViewportSize() + + var cmd tea.Cmd + if s.filtering { + s.filter, cmd = s.filter.Update(msg) + } + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + s.hasDarkBg = msg.IsDark() + case updateFieldMsg: + return s, nil + + case tea.KeyPressMsg: + s.err = nil + switch { + case key.Matches(msg, s.keymap.Filter): + s.setFiltering(true) + return s, s.filter.Focus() + case key.Matches(msg, s.keymap.SetFilter): + if len(s.filteredOptions) <= 0 { + s.filter.SetValue("") + s.filteredOptions = s.options + } + s.setFiltering(false) + case key.Matches(msg, s.keymap.ClearFilter): + s.clearFilter() + case key.Matches(msg, s.keymap.Up, s.keymap.Left): + // When filtering we should ignore j/k keybindings + // + // XXX: Currently, the below check doesn't account for keymap + // changes. When making this fix it's worth considering ignoring + // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 + // may not be enough when international keyboards are considered. + if s.filtering && (msg.String() == "k" || msg.String() == "h") { + break + } + s.selected = s.selected - 1 + if s.selected < 0 { + s.selected = len(s.filteredOptions) - 1 + s.viewport.GotoBottom() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msg, s.keymap.GotoTop): + if s.filtering { + break + } + s.selected = 0 + s.viewport.GotoTop() + s.updateValue() + case key.Matches(msg, s.keymap.GotoBottom): + if s.filtering { + break + } + s.selected = len(s.filteredOptions) - 1 + s.viewport.GotoBottom() + case key.Matches(msg, s.keymap.HalfPageUp): + s.selected = max(s.selected-s.viewport.Height()/2, 0) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msg, s.keymap.HalfPageDown): + s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msg, s.keymap.Down, s.keymap.Right): + // When filtering we should ignore j/k keybindings + // + // XXX: See note in the previous case match. + if s.filtering && (msg.String() == "j" || msg.String() == "l") { + break + } + s.selected = s.selected + 1 + if s.selected > len(s.filteredOptions)-1 { + s.selected = 0 + s.viewport.GotoTop() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msg, s.keymap.Prev): + if s.selected >= len(s.filteredOptions) { + break + } + s.updateValue() + s.err = s.validate(s.accessor.Get()) + if s.err != nil { + return s, nil + } + s.updateValue() + return s, PrevField + case key.Matches(msg, s.keymap.Next, s.keymap.Submit): + if s.selected >= len(s.filteredOptions) { + break + } + s.setFiltering(false) + s.updateValue() + s.err = s.validate(s.accessor.Get()) + if s.err != nil { + return s, nil + } + s.updateValue() + return s, NextField + } + + if s.filtering { + s.filteredOptions = s.options + if s.filter.Value() != "" { + s.filteredOptions = nil + for _, option := range s.options { + if s.filterFunc(option.Key) { + s.filteredOptions = append(s.filteredOptions, option) + } + } + } + if len(s.filteredOptions) > 0 { + s.selected = min(s.selected, len(s.filteredOptions)-1) + } + } + + s.ensureCursorVisible() + } + + return s, cmd +} + +func (s *Select[T]) updateValue() { + if s.selected < len(s.filteredOptions) && s.selected >= 0 { + s.accessor.Set(s.filteredOptions[s.selected].Value) + } +} + +// updateViewportSize updates the viewport size according to the Height setting +// on this select field. +func (s *Select[T]) updateViewportSize() { + if s.height > 0 { + yoffset := 0 + if ss := s.titleView(); ss != "" { + yoffset += lipgloss.Height(ss) + } + if ss := s.descriptionView(); ss != "" { + yoffset += lipgloss.Height(ss) + } + s.viewport.SetHeight(max(minHeight, s.height-yoffset)) + s.ensureCursorVisible() + } else { + // If no height is set size the viewport to the number of options. + v, _, _ := s.optionsView() + s.viewport.SetHeight(lipgloss.Height(v)) + } + if s.width > 0 { + s.viewport.SetWidth(s.width) + } else { + v, _, _ := s.optionsView() + s.viewport.SetWidth(lipgloss.Width(v)) + } +} + +func (s *Select[T]) activeStyles() *FieldStyles { + theme := s.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if s.focused { + return &theme.Theme(s.hasDarkBg).Focused + } + return &theme.Theme(s.hasDarkBg).Blurred +} + +func (s *Select[T]) titleView() string { + var ( + styles = s.activeStyles() + sb = strings.Builder{} + maxWidth = s.width - styles.Base.GetHorizontalFrameSize() + ) + if s.filtering { + sb.WriteString(s.filter.View()) + } else if s.filter.Value() != "" && !s.inline { + sb.WriteString(styles.Description.Render("/" + s.filter.Value())) + } else { + sb.WriteString(styles.Title.Render(wrap(s.title, maxWidth))) + } + if s.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + } + return sb.String() +} + +func (s *Select[T]) descriptionView() string { + if s.description == "" { + return "" + } + maxWidth := s.width - s.activeStyles().Base.GetHorizontalFrameSize() + return s.activeStyles().Description.Render(wrap(s.description, maxWidth)) +} + +func (s *Select[T]) optionsView() (string, int, int) { + var ( + styles = s.activeStyles() + sb strings.Builder + ) + + if s.inline { + option := styles.TextInput.Placeholder.Render("No matches") + if len(s.filteredOptions) > 0 { + option = styles.SelectedOption.Render(s.filteredOptions[s.selected].Key) + } + return lipgloss.NewStyle(). + Width(s.width). + Render(lipgloss.JoinHorizontal( + lipgloss.Left, + styles.PrevIndicator.Faint(s.selected <= 0).String(), + option, + styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String(), + )), + -1, 1 + } + + var cursorOffset int + var cursorHeight int + for i, option := range s.filteredOptions { + selected := s.selected == i + line := s.renderOption(option, selected) + if i < s.selected { + cursorOffset += lipgloss.Height(line) + } + if selected { + cursorHeight = lipgloss.Height(line) + } + + sb.WriteString(line) + if i < len(s.options)-1 { + sb.WriteString("\n") + } + } + + for i := len(s.filteredOptions); i < len(s.options)-1; i++ { + sb.WriteString("\n") + } + + return sb.String(), cursorOffset, cursorHeight +} + +// cursorLineOffset computes the line offset and height (in lines) for the +// currently selected option without rendering the full options string. +func (s *Select[T]) cursorLineOffset() (offset int, height int) { + for i, option := range s.filteredOptions { + line := s.renderOption(option, s.selected == i) + h := lipgloss.Height(line) + if i < s.selected { + offset += h + } + if i == s.selected { + height = h + return offset, height + } + } + return offset, height +} + +// ensureVisible scrolls a viewport the minimum amount so that the region +// [offset, offset+height) is within the visible area. +func ensureVisible(vp *viewport.Model, offset, height int) { + if height <= 0 { + return + } + yOff := vp.YOffset() + vHeight := vp.Height() + if offset < yOff { + vp.ScrollUp(yOff - offset) + } else if offset+height > yOff+vHeight { + vp.ScrollDown(offset + height - yOff - vHeight) + } +} + +func (s *Select[T]) ensureCursorVisible() { + offset, height := s.cursorLineOffset() + ensureVisible(&s.viewport, offset, height) +} + +func (s *Select[T]) renderOption(option Option[T], selected bool) string { + var ( + styles = s.activeStyles() + cursor = styles.SelectSelector.String() + cursorW = lipgloss.Width(cursor) + maxWidth = s.width - s.activeStyles().Base.GetHorizontalFrameSize() - cursorW + ) + + key := wrap(option.Key, maxWidth) + + if selected { + return lipgloss.JoinHorizontal( + lipgloss.Left, + cursor, + styles.SelectedOption.Render(key), + ) + } + return lipgloss.JoinHorizontal( + lipgloss.Left, + strings.Repeat(" ", cursorW), + styles.UnselectedOption.Render(key), + ) +} + +// View renders the select field. +func (s *Select[T]) View() string { + styles := s.activeStyles() + vpc, _, _ := s.optionsView() + s.viewport.SetContent(vpc) + + var parts []string + if s.title != "" { + parts = append(parts, s.titleView()) + } + if s.description != "" { + parts = append(parts, s.descriptionView()) + } + parts = append(parts, s.viewport.View()) + return styles.Base.Width(s.width).Height(s.height). + Render(strings.Join(parts, "\n")) +} + +// clearFilter clears the value of the filter. +func (s *Select[T]) clearFilter() { + s.filter.SetValue("") + s.filteredOptions = s.options + s.setFiltering(false) +} + +// setFiltering sets the filter of the select field. +func (s *Select[T]) setFiltering(filtering bool) { + if s.inline && filtering { + s.filter.SetWidth(lipgloss.Width(s.titleView()) - 1 - 1) + } + s.filtering = filtering + s.keymap.SetFilter.SetEnabled(filtering) + s.keymap.Filter.SetEnabled(!filtering) + s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") +} + +// filterFunc returns true if the option matches the filter. +func (s *Select[T]) filterFunc(option string) bool { + // XXX: remove diacritics or allow customization of filter function. + return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) +} + +// RunAccessible runs an accessible select field. +func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error { + styles := s.activeStyles() + _, _ = fmt.Fprintln(w, styles.Title. + PaddingRight(1). + Render(cmp.Or(s.title, "Select:"))) + + for i, option := range s.options { + _, _ = fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) + } + + var defaultValue *int + switch s.accessor.(type) { + case *PointerAccessor[T]: // if its of this type, it means it has a default value + s.selectOption() // make sure s.selected is set + idx := s.selected + 1 + defaultValue = &idx + } + prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) + if len(s.options) == 1 { + prompt = "There is only one option available; enter the number 1:" + } + for { + choice := promptInt(w, r, prompt, 1, len(s.options), defaultValue) + option := s.options[choice-1] + if err := s.validate(option.Value); err != nil { + _, _ = fmt.Fprintln(w, err.Error()) + _, _ = fmt.Fprintln(w) + continue + } + s.accessor.Set(option.Value) + return nil + } +} + +// WithTheme sets the theme of the select field. +func (s *Select[T]) WithTheme(theme Theme) Field { + if s.theme != nil { + return s + } + s.theme = theme + styles := s.theme.Theme(s.hasDarkBg) + + st := s.filter.Styles() + st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.Focused.TextInput.Prompt + st.Focused.Text = styles.Focused.TextInput.Text + st.Focused.Placeholder = styles.Focused.TextInput.Placeholder + s.filter.SetStyles(st) + + s.updateViewportSize() + return s +} + +// WithKeyMap sets the keymap on a select field. +func (s *Select[T]) WithKeyMap(k *KeyMap) Field { + s.keymap = k.Select + s.keymap.Left.SetEnabled(s.inline) + s.keymap.Right.SetEnabled(s.inline) + s.keymap.Up.SetEnabled(!s.inline) + s.keymap.Down.SetEnabled(!s.inline) + return s +} + +// WithWidth sets the width of the select field. +func (s *Select[T]) WithWidth(width int) Field { + s.width = width + s.updateViewportSize() + return s +} + +// WithHeight sets the height of the select field. +func (s *Select[T]) WithHeight(height int) Field { + return s.Height(height) +} + +// WithPosition sets the position of the select field. +func (s *Select[T]) WithPosition(p FieldPosition) Field { + if s.filtering { + return s + } + s.keymap.Prev.SetEnabled(!p.IsFirst()) + s.keymap.Next.SetEnabled(!p.IsLast()) + s.keymap.Submit.SetEnabled(p.IsLast()) + return s +} + +// GetValue returns the value of the field. +func (s *Select[T]) GetValue() any { + return s.accessor.Get() +} + +// GetFiltering returns the filtering state of the field. +func (s *Select[T]) GetFiltering() bool { + return s.filtering +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl new file mode 100644 index 000000000..1b82adc7f --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl @@ -0,0 +1,386 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "cmp" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Text is a text field. +// +// A text box is responsible for getting multi-line input from the user. Use +// it to gather longer-form user input. The Text field can be filled with an +// EDITOR. +type Text struct { + accessor Accessor[string] + + title string + description string + + externalEditor bool + editorCmd string + editorArgs []string + editorExtension string + + textarea textarea.Model + + focused bool + validate func(string) error + err error + + width int + + theme Theme + hasDarkBg bool + keymap TextKeyMap +} + +// NewText creates a new text field. +// +// A text box is responsible for getting multi-line input from the user. Use +// it to gather longer-form user input. The Text field can be filled with an +// EDITOR. +func NewText() *Text { + text := textarea.New() + text.ShowLineNumbers = false + text.Prompt = "" + st := text.Styles() + st.Focused.CursorLine = lipgloss.NewStyle() + text.SetStyles(st) + + editorCmd, editorArgs := getEditor() + + t := &Text{ + accessor: &EmbeddedAccessor[string]{}, + textarea: text, + validate: func(string) error { return nil }, + externalEditor: true, + editorCmd: editorCmd, + editorArgs: editorArgs, + editorExtension: "md", + } + + return t +} + +// Value sets the value of the text field. +func (t *Text) Value(value *string) *Text { + return t.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the text field. +func (t *Text) Accessor(accessor Accessor[string]) *Text { + t.accessor = accessor + t.textarea.SetValue(t.accessor.Get()) + return t +} + +// Title sets the text field's title. +func (t *Text) Title(title string) *Text { + t.title = title + return t +} + +// Description sets the description of the text field. +func (t *Text) Description(description string) *Text { + t.description = description + return t +} + +// Lines sets the number of lines to show of the text field. +func (t *Text) Lines(lines int) *Text { + t.textarea.SetHeight(lines) + return t +} + +// CharLimit sets the character limit of the text field. +func (t *Text) CharLimit(charlimit int) *Text { + t.textarea.CharLimit = charlimit + return t +} + +// ShowLineNumbers sets whether or not to show line numbers. +func (t *Text) ShowLineNumbers(show bool) *Text { + t.textarea.ShowLineNumbers = show + return t +} + +// Placeholder sets the placeholder of the text field. +// +// This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`. +func (t *Text) Placeholder(str string) *Text { + t.textarea.Placeholder = str + return t +} + +// Validate sets the validation function of the text field. +func (t *Text) Validate(validate func(string) error) *Text { + t.validate = validate + return t +} + +// ExternalEditor sets whether option to launch an editor is available. +func (t *Text) ExternalEditor(enabled bool) *Text { + t.externalEditor = enabled + return t +} + +const defaultEditor = "nano" + +// getEditor returns the editor command and arguments. +func getEditor() (string, []string) { + editor := strings.Fields(os.Getenv("EDITOR")) + if len(editor) > 0 { + return editor[0], editor[1:] + } + return defaultEditor, nil +} + +// Editor specifies which editor to use. +// +// The first argument provided is used as the editor command (vim, nvim, nano, etc...) +// The following (optional) arguments provided are passed as arguments to the editor command. +func (t *Text) Editor(editor ...string) *Text { + if len(editor) > 0 { + t.editorCmd = editor[0] + } + if len(editor) > 1 { + t.editorArgs = editor[1:] + } + return t +} + +// EditorExtension specifies arguments to pass into the editor. +func (t *Text) EditorExtension(extension string) *Text { + t.editorExtension = extension + return t +} + +// Error returns the error of the text field. +func (t *Text) Error() error { return t.err } + +// Skip returns whether the textarea should be skipped or should be blocking. +func (*Text) Skip() bool { return false } + +// Zoom returns whether the note should be zoomed. +func (*Text) Zoom() bool { return false } + +// Focus focuses the text field. +func (t *Text) Focus() tea.Cmd { + t.focused = true + return t.textarea.Focus() +} + +// Blur blurs the text field. +func (t *Text) Blur() tea.Cmd { + t.focused = false + t.accessor.Set(t.textarea.Value()) + t.textarea.Blur() + t.err = t.validate(t.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the text field. +func (t *Text) KeyBinds() []key.Binding { + t.keymap.Editor.SetEnabled(t.externalEditor) + return []key.Binding{t.keymap.NewLine, t.keymap.Editor, t.keymap.Prev, t.keymap.Submit, t.keymap.Next} +} + +type updateValueMsg []byte + +// Init initializes the text field. +func (t *Text) Init() tea.Cmd { + t.textarea.Blur() + return nil +} + +// Update updates the text field. +func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + t.hasDarkBg = msg.IsDark() + case updateValueMsg: + t.textarea.SetValue(string(msg)) + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + t.accessor.Set(t.textarea.Value()) + case updateFieldMsg: + return t, nil + case tea.KeyPressMsg: + t.err = nil + + switch { + case key.Matches(msg, t.keymap.Editor): + ext := strings.TrimPrefix(t.editorExtension, ".") + tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext) + cmd := exec.CommandContext( + context.TODO(), + t.editorCmd, + append(t.editorArgs, tmpFile.Name())..., + ) + _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) + cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg { + content, _ := os.ReadFile(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) + return updateValueMsg(content) + })) + case key.Matches(msg, t.keymap.Next, t.keymap.Submit): + value := t.textarea.Value() + t.err = t.validate(value) + if t.err != nil { + return t, nil + } + cmds = append(cmds, NextField) + case key.Matches(msg, t.keymap.Prev): + value := t.textarea.Value() + t.err = t.validate(value) + if t.err != nil { + return t, nil + } + cmds = append(cmds, PrevField) + } + } + + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + t.accessor.Set(t.textarea.Value()) + + return t, tea.Batch(cmds...) +} + +func (t *Text) activeStyles() *FieldStyles { + theme := t.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if t.focused { + return &theme.Theme(t.hasDarkBg).Focused + } + return &theme.Theme(t.hasDarkBg).Blurred +} + +// View renders the text field. +func (t *Text) View() string { + styles := t.activeStyles() + st := t.textarea.Styles() + + if t.focused { + st.Focused.Placeholder = styles.TextInput.Placeholder + st.Focused.Text = styles.TextInput.Text + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.CursorLine = styles.TextInput.Text + } else { + st.Blurred.Placeholder = styles.TextInput.Placeholder + st.Blurred.Text = styles.TextInput.Text + st.Blurred.Prompt = styles.TextInput.Prompt + st.Blurred.CursorLine = styles.TextInput.Text + } + st.Cursor.Color = styles.TextInput.Cursor.GetBackground() + t.textarea.SetStyles(st) + + maxWidth := t.width - styles.Base.GetHorizontalFrameSize() + var parts []string + if t.title != "" { + header := styles.Title.Render(wrap(t.title, maxWidth)) + if t.err != nil { + header += styles.ErrorIndicator.String() + } + parts = append(parts, header) + } + if t.description != "" { + parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) + } + parts = append(parts, t.textarea.View()) + + return styles.Base. + Render(strings.Join(parts, "\n")) +} + +// RunAccessible runs an accessible text field. +func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { + styles := t.activeStyles() + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(t.title, "Input:")) + t.accessor.Set(promptString( + w, + r, + prompt, + t.GetValue().(string), + func(input string) error { + if err := t.validate(input); err != nil { + // Handle the error from t.validate, return it + return err + } + + if t.textarea.CharLimit > 0 && len(input) > t.textarea.CharLimit { + return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit) + } + return nil + }, + )) + return nil +} + +// WithTheme sets the theme on a text field. +func (t *Text) WithTheme(theme Theme) Field { + if t.theme != nil { + return t + } + t.theme = theme + return t +} + +// WithKeyMap sets the keymap on a text field. +func (t *Text) WithKeyMap(k *KeyMap) Field { + t.keymap = k.Text + t.textarea.KeyMap.InsertNewline.SetKeys(t.keymap.NewLine.Keys()...) + return t +} + +// WithWidth sets the width of the text field. +func (t *Text) WithWidth(width int) Field { + t.width = width + t.textarea.SetWidth(width - t.activeStyles().Base.GetHorizontalFrameSize()) + return t +} + +// WithHeight sets the height of the text field. +func (t *Text) WithHeight(height int) Field { + adjust := 0 + if t.title != "" { + adjust++ + } + if t.description != "" { + adjust++ + } + t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) + return t +} + +// WithPosition sets the position information of the text field. +func (t *Text) WithPosition(p FieldPosition) Field { + t.keymap.Prev.SetEnabled(!p.IsFirst()) + t.keymap.Next.SetEnabled(!p.IsLast()) + t.keymap.Submit.SetEnabled(p.IsLast()) + return t +} + +// GetValue returns the value of the field. +func (t *Text) GetValue() any { + return t.accessor.Get() +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl new file mode 100644 index 000000000..e37f99613 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl @@ -0,0 +1,585 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "cmp" + "errors" + "fmt" + "io" + "os" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +const defaultWidth = 80 + +// Model is a Bubble Tea model whose Update returns the concrete field, group +// or form type rather than [tea.Model]. +type Model interface { + Init() tea.Cmd + Update(msg tea.Msg) (Model, tea.Cmd) + View() string +} + +// viewModel adapts a [Model] to [tea.Model]. +type viewModel struct { + Model + viewHook func(tea.View) tea.View +} + +// Update implements [tea.Model]. +func (w viewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := w.Model.Update(msg) + return viewModel{ + Model: m, + viewHook: w.viewHook, + }, cmd +} + +// View implements [tea.Model]. +func (w viewModel) View() tea.View { + var view tea.View + if w.viewHook != nil { + view = w.viewHook(view) + } + view.SetContent(w.Model.View()) + return view +} + +// FormState represents the current state of the form. +type FormState int + +const ( + // StateNormal is when the user is completing the form. + StateNormal FormState = iota + + // StateCompleted is when the user has completed the form. + StateCompleted + + // StateAborted is when the user has aborted the form. + StateAborted +) + +// ErrUserAborted is the error returned when a user exits the form before submitting. +var ErrUserAborted = errors.New("user aborted") + +// Form is a collection of groups that are displayed one at a time on a "page". +// +// The form can navigate between groups and is complete once all the groups are +// complete. +type Form struct { + // collection of groups + selector *selector[*Group] + + // callbacks + SubmitCmd tea.Cmd + CancelCmd tea.Cmd + + State FormState + + // whether or not to use bubble tea rendering for accessibility + // purposes, if true, the form will render with basic prompting primitives + // to be more accessible to screen readers. + accessible bool + + quitting bool + aborted bool + + // options + width int + height int + theme Theme + hasDarkBg bool + keymap *KeyMap + teaOptions []tea.ProgramOption + + // accessible mode IO + output io.Writer + input io.Reader +} + +// NewForm returns a form with the given groups and default themes and +// keybindings. +// +// Use With* methods to customize the form with options, such as setting +// different themes and keybindings. +func NewForm(groups ...*Group) *Form { + selector := newSelector(groups) + + f := &Form{ + selector: selector, + keymap: NewDefaultKeyMap(), + teaOptions: []tea.ProgramOption{ + tea.WithOutput(os.Stderr), + }, + } + + // NB: If dynamic forms come into play this will need to be applied when + // groups and fields are added. + f.WithKeyMap(f.keymap) + f.WithWidth(f.width) + f.WithHeight(f.height) + f.UpdateFieldPositions() + + if os.Getenv("TERM") == "dumb" { + f.WithWidth(defaultWidth) + f.WithAccessible(true) + } + + return f +} + +// Field is a primitive of a form. +// +// A field represents a single input control on a form such as a text input, +// confirm button, select option, etc... +// +// Each field implements the Bubble Tea Model interface. +type Field interface { + // Bubble Tea Model + Model + + // Bubble Tea Events + Blur() tea.Cmd + Focus() tea.Cmd + + // Errors and Validation + Error() error + + // RunAccessible runs the field in accessible mode with the given IO. + RunAccessible(w io.Writer, r io.Reader) error + + // Skip returns whether this input should be skipped or not. + Skip() bool + + // Zoom returns whether this input should be zoomed or not. + // Zoom allows the field to take focus of the group / form height. + Zoom() bool + + // KeyBinds returns help keybindings. + KeyBinds() []key.Binding + + // WithTheme sets the theme on a field. + WithTheme(Theme) Field + + // WithKeyMap sets the keymap on a field. + WithKeyMap(*KeyMap) Field + + // WithWidth sets the width of a field. + WithWidth(int) Field + + // WithHeight sets the height of a field. + WithHeight(int) Field + + // WithPosition tells the field the index of the group and position it is in. + WithPosition(FieldPosition) Field + + // GetValue returns the field's value. + GetValue() any +} + +// FieldPosition is positional information about the given field and form. +type FieldPosition struct { + Group int + Field int + FirstField int + LastField int + GroupCount int + FirstGroup int + LastGroup int +} + +// IsFirst returns whether a field is the form's first field. +func (p FieldPosition) IsFirst() bool { + return p.Field == p.FirstField && p.Group == p.FirstGroup +} + +// IsLast returns whether a field is the form's last field. +func (p FieldPosition) IsLast() bool { + return p.Field == p.LastField && p.Group == p.LastGroup +} + +// nextGroupMsg is a message to move to the next group. +type nextGroupMsg struct{} + +// prevGroupMsg is a message to move to the previous group. +type prevGroupMsg struct{} + +// nextGroup is the command to move to the next group. +func nextGroup() tea.Msg { + return nextGroupMsg{} +} + +// prevGroup is the command to move to the previous group. +func prevGroup() tea.Msg { + return prevGroupMsg{} +} + +// WithAccessible sets the form to run in accessible mode to avoid redrawing the +// views which makes it easier for screen readers to read and describe the form. +// +// This avoids using the Bubble Tea renderer and instead simply uses basic +// terminal prompting to gather input which degrades the user experience but +// provides accessibility. +func (f *Form) WithAccessible(accessible bool) *Form { + f.accessible = accessible + return f +} + +// WithShowHelp sets whether or not the form should show help. +// +// This allows the form groups and field to show what keybindings are available +// to the user. +func (f *Form) WithShowHelp(v bool) *Form { + f.selector.Range(func(_ int, group *Group) bool { + group.WithShowHelp(v) + return true + }) + return f +} + +// WithTheme sets the theme on a form. +// +// This allows all groups and fields to be themed consistently, however themes +// can be applied to each group and field individually for more granular +// control. +func (f *Form) WithTheme(theme Theme) *Form { + if theme == nil { + return f + } + f.theme = theme + f.selector.Range(func(_ int, group *Group) bool { + group.WithTheme(theme) + return true + }) + return f +} + +// WithKeyMap sets the keymap on a form. +// +// This allows customization of the form key bindings. +func (f *Form) WithKeyMap(keymap *KeyMap) *Form { + if keymap == nil { + return f + } + f.keymap = keymap + f.selector.Range(func(_ int, group *Group) bool { + group.WithKeyMap(keymap) + return true + }) + f.UpdateFieldPositions() + return f +} + +// WithWidth sets the width of a form. +// +// This allows all groups and fields to be sized consistently, however width +// can be applied to each group and field individually for more granular +// control. +func (f *Form) WithWidth(width int) *Form { + if width <= 0 { + return f + } + f.width = width + f.selector.Range(func(_ int, group *Group) bool { + group.WithWidth(width) + return true + }) + return f +} + +// WithHeight sets the height of a form. +func (f *Form) WithHeight(height int) *Form { + if height <= 0 { + return f + } + f.height = height + f.selector.Range(func(_ int, group *Group) bool { + group.WithHeight(height) + return true + }) + return f +} + +// WithOutput sets the io.Writer to output the form. +// Default is STDOUT when [Form] is accessible (set with [Form.WithAccessible], STDERR otherwise. +func (f *Form) WithOutput(w io.Writer) *Form { + f.output = w + f.teaOptions = append(f.teaOptions, tea.WithOutput(w)) + return f +} + +// WithInput sets the io.Reader to the input form. +// Default is STDIN. +func (f *Form) WithInput(r io.Reader) *Form { + f.input = r + f.teaOptions = append(f.teaOptions, tea.WithInput(r)) + return f +} + +// UpdateFieldPositions sets the position on all the fields. +func (f *Form) UpdateFieldPositions() *Form { + firstGroup := 0 + lastGroup := f.selector.Total() - 1 + + // determine the first non-hidden group. + f.selector.Range(func(_ int, g *Group) bool { + if !f.isGroupHidden(g) { + return false + } + firstGroup++ + return true + }) + + // determine the last non-hidden group. + f.selector.ReverseRange(func(_ int, g *Group) bool { + if !f.isGroupHidden(g) { + return false + } + lastGroup-- + return true + }) + + f.selector.Range(func(g int, group *Group) bool { + // determine the first non-skippable field. + var firstField int + group.selector.Range(func(_ int, field Field) bool { + if !field.Skip() || group.selector.Total() == 1 { + return false + } + firstField++ + return true + }) + + // determine the last non-skippable field. + var lastField int + group.selector.ReverseRange(func(i int, field Field) bool { + lastField = i + if !field.Skip() || group.selector.Total() == 1 { + return false + } + return true + }) + + group.selector.Range(func(i int, field Field) bool { + field.WithPosition(FieldPosition{ + Group: g, + Field: i, + FirstField: firstField, + LastField: lastField, + FirstGroup: firstGroup, + LastGroup: lastGroup, + }) + return true + }) + + return true + }) + return f +} + +// Init initializes the form. +func (f *Form) Init() tea.Cmd { + var cmds []tea.Cmd + f.selector.Range(func(i int, group *Group) bool { + if i == 0 { + group.active = true + } + cmds = append(cmds, group.Init()) + return true + }) + + if f.isGroupHidden(f.selector.Selected()) { + cmds = append(cmds, nextGroup) + } + + cmds = append(cmds, tea.RequestWindowSize) + return tea.Sequence(cmds...) +} + +// Update updates the form. +func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) { + // If the form is aborted or completed there's no need to update it. + if f.State != StateNormal { + return f, nil + } + + group := f.selector.Selected() + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + f.hasDarkBg = msg.IsDark() + case tea.WindowSizeMsg: + if f.width == 0 { + f.selector.Range(func(_ int, group *Group) bool { + group.WithWidth(msg.Width) + return true + }) + } + if f.height == 0 { + // calculate the needed height, which is the height of the + // heightest group, accounting for the width, wraps, etc. + neededHeight := 0 + f.selector.Range(func(_ int, group *Group) bool { + neededHeight = max(neededHeight, group.rawHeight()) + return true + }) + + f.selector.Range(func(_ int, group *Group) bool { + group.WithHeight(min(neededHeight, msg.Height)) + return true + }) + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.keymap.Quit): + f.aborted = true + f.quitting = true + f.State = StateAborted + return f, f.CancelCmd + } + + case nextGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + + submit := func() (Model, tea.Cmd) { + f.quitting = true + f.State = StateCompleted + return f, f.SubmitCmd + } + + if f.selector.OnLast() { + return submit() + } + + for i := f.selector.Index() + 1; i < f.selector.Total(); i++ { + if !f.isGroupHidden(f.selector.Get(i)) { + f.selector.SetIndex(i) + break + } + // all subsequent groups are hidden, so we must act as + // if we were in the last one. + if i == f.selector.Total()-1 { + return submit() + } + } + f.selector.Selected().active = true + return f, f.selector.Selected().Init() + + case prevGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + + for i := f.selector.Index() - 1; i >= 0; i-- { + if !f.isGroupHidden(f.selector.Get(i)) { + f.selector.SetIndex(i) + break + } + } + + f.selector.Selected().active = true + return f, f.selector.Selected().Init() + } + + m, cmd := group.Update(msg) + f.selector.Set(f.selector.Index(), m.(*Group)) + + // A user input a key, this could hide or show other groups, + // let's update all of their positions. + switch msg.(type) { + case tea.KeyPressMsg: + f.UpdateFieldPositions() + } + + return f, cmd +} + +func (f *Form) isGroupHidden(group *Group) bool { + hide := group.hide + if hide == nil { + return false + } + return hide() +} + +func (f *Form) getTheme() *Styles { + if f.theme != nil { + return f.theme.Theme(f.hasDarkBg) + } + return themeCharm(f.hasDarkBg) +} + +func (f *Form) styles() FormStyles { + return f.getTheme().Form +} + +// View renders the form. +func (f *Form) View() string { + if f.quitting { + return "" + } + + return f.styles().Base.Render(f.selector.Selected().View()) +} + +// Run runs the form. +func (f *Form) Run() error { + f.SubmitCmd = tea.Quit + f.CancelCmd = tea.Interrupt + + if f.selector.Total() == 0 { + return nil + } + + if f.accessible { + return f.runAccessible( + cmp.Or[io.Writer](f.output, os.Stdout), + cmp.Or[io.Reader](f.input, os.Stdin), + ) + } + + return f.run() +} + +// run runs the form in normal mode. +func (f *Form) run() error { + m, err := tea.NewProgram( + viewModel{ + Model: f, + viewHook: func(v tea.View) tea.View { + v.ReportFocus = true + return v + }, + }, + f.teaOptions..., + ).Run() + if m.(viewModel).Model.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + if err != nil { + return fmt.Errorf("form: %w", err) + } + return nil +} + +// runAccessible runs the form in accessible mode. +func (f *Form) runAccessible(w io.Writer, r io.Reader) error { + f.selector.Range(func(_ int, group *Group) bool { + group.selector.Range(func(_ int, field Field) bool { + field.Init() + field.Focus() + _ = field.RunAccessible(w, r) + _, _ = fmt.Fprintln(w) + return true + }) + return true + }) + + return nil +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl new file mode 100644 index 000000000..9e1cd3ecb --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl @@ -0,0 +1,410 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Group is a collection of fields that are displayed together with a page of +// the form. While a group is displayed the form completer can switch between +// fields in the group. +// +// If any of the fields in a group have errors, the form will not be able to +// progress to the next group. +type Group struct { + // collection of fields + selector *selector[Field] + + // information + title string + description string + + // navigation + viewport viewport.Model + + // help + showHelp bool + help help.Model + + // errors + showErrors bool + + // group options + width int + height int + theme Theme + hasDarkBg bool + keymap *KeyMap + hide func() bool + active bool +} + +// NewGroup returns a new group with the given fields. +func NewGroup(fields ...Field) *Group { + selector := newSelector(fields) + group := &Group{ + selector: selector, + help: help.New(), + showHelp: true, + showErrors: true, + active: false, + } + + group.width = 80 + height := group.rawHeight() + v := viewport.New( + viewport.WithWidth(group.width), + viewport.WithHeight(height), + ) + group.viewport = v + group.height = height + + return group +} + +// Title sets the group's title. +func (g *Group) Title(title string) *Group { + g.title = title + return g +} + +// Description sets the group's description. +func (g *Group) Description(description string) *Group { + g.description = description + return g +} + +// WithShowHelp sets whether or not the group's help should be shown. +func (g *Group) WithShowHelp(show bool) *Group { + g.showHelp = show + return g +} + +// WithShowErrors sets whether or not the group's errors should be shown. +func (g *Group) WithShowErrors(show bool) *Group { + g.showErrors = show + return g +} + +// WithTheme sets the theme on a group. +func (g *Group) WithTheme(t Theme) *Group { + g.theme = t + g.help.Styles = t.Theme(g.hasDarkBg).Help + g.selector.Range(func(_ int, field Field) bool { + field.WithTheme(t) + return true + }) + if g.height <= 0 { + g.WithHeight(g.rawHeight()) + } + return g +} + +// WithKeyMap sets the keymap on a group. +func (g *Group) WithKeyMap(k *KeyMap) *Group { + g.keymap = k + g.selector.Range(func(_ int, field Field) bool { + field.WithKeyMap(k) + return true + }) + return g +} + +// WithWidth sets the width on a group. +func (g *Group) WithWidth(width int) *Group { + g.width = width + g.viewport.SetWidth(width) + g.help.SetWidth(width) + g.selector.Range(func(_ int, field Field) bool { + field.WithWidth(width) + return true + }) + return g +} + +// WithHeight sets the height on a group. +func (g *Group) WithHeight(height int) *Group { + g.height = height + h := height - g.titleFooterHeight() + g.viewport.SetHeight(h) + g.selector.Range(func(_ int, field Field) bool { + // A field height must not exceed the form height. + if h < lipgloss.Height(field.View()) { + field.WithHeight(h) + } + return true + }) + return g +} + +// WithHide sets whether this group should be skipped. +func (g *Group) WithHide(hide bool) *Group { + g.WithHideFunc(func() bool { return hide }) + return g +} + +// WithHideFunc sets the function that checks if this group should be skipped. +func (g *Group) WithHideFunc(hideFunc func() bool) *Group { + g.hide = hideFunc + return g +} + +// Errors returns the groups' fields' errors. +func (g *Group) Errors() []error { + var errs []error + g.selector.Range(func(_ int, field Field) bool { + if err := field.Error(); err != nil { + errs = append(errs, err) + } + return true + }) + return errs +} + +// updateFieldMsg is a message to update the fields of a group that is currently +// displayed. +// +// This is used to update all TitleFunc, DescriptionFunc, and ...Func update +// methods to make all fields dynamically update based on user input. +type updateFieldMsg struct{} + +// nextFieldMsg is a message to move to the next field, +// +// each field controls when to send this message such that it is able to use +// different key bindings or events to trigger group progression. +type nextFieldMsg struct{} + +// prevFieldMsg is a message to move to the previous field. +// +// each field controls when to send this message such that it is able to use +// different key bindings or events to trigger group progression. +type prevFieldMsg struct{} + +// NextField is the command to move to the next field. +func NextField() tea.Msg { + return nextFieldMsg{} +} + +// PrevField is the command to move to the previous field. +func PrevField() tea.Msg { + return prevFieldMsg{} +} + +// Init initializes the group. +func (g *Group) Init() tea.Cmd { + var cmds []tea.Cmd + + cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) + + if g.selector.Selected().Skip() { + if g.selector.OnLast() { + cmds = append(cmds, g.prevField()...) + } else if g.selector.OnFirst() { + cmds = append(cmds, g.nextField()...) + } + return tea.Batch(cmds...) + } + + if g.active { + cmd := g.selector.Selected().Focus() + cmds = append(cmds, cmd) + } + g.buildView() + return tea.Batch(cmds...) +} + +// nextField moves to the next field. +func (g *Group) nextField() []tea.Cmd { + blurCmd := g.selector.Selected().Blur() + if g.selector.OnLast() { + return []tea.Cmd{blurCmd, nextGroup} + } + g.selector.Next() + for g.selector.Selected().Skip() { + if g.selector.OnLast() { + return []tea.Cmd{blurCmd, nextGroup} + } + g.selector.Next() + } + focusCmd := g.selector.Selected().Focus() + return []tea.Cmd{blurCmd, focusCmd} +} + +// prevField moves to the previous field. +func (g *Group) prevField() []tea.Cmd { + blurCmd := g.selector.Selected().Blur() + if g.selector.OnFirst() { + return []tea.Cmd{blurCmd, prevGroup} + } + g.selector.Prev() + for g.selector.Selected().Skip() { + if g.selector.OnFirst() { + return []tea.Cmd{blurCmd, prevGroup} + } + g.selector.Prev() + } + focusCmd := g.selector.Selected().Focus() + return []tea.Cmd{blurCmd, focusCmd} +} + +// Update updates the group. +func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update all the fields in the group. + g.selector.Range(func(i int, field Field) bool { + switch msg := msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + break + default: + m, cmd := field.Update(msg) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + } + if g.selector.Index() == i { + m, cmd := field.Update(msg) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + } + m, cmd := field.Update(updateFieldMsg{}) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + return true + }) + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + g.hasDarkBg = msg.IsDark() + case nextFieldMsg: + cmds = append(cmds, g.nextField()...) + case prevFieldMsg: + cmds = append(cmds, g.prevField()...) + } + + g.buildView() + + return g, tea.Batch(cmds...) +} + +func (g *Group) getTheme() *Styles { + if theme := g.theme; theme != nil { + return theme.Theme(g.hasDarkBg) + } + return ThemeFunc(themeCharm).Theme(g.hasDarkBg) +} + +func (g *Group) styles() GroupStyles { return g.getTheme().Group } + +func (g *Group) getContent() (int, string) { + var fields strings.Builder + offset := 0 + + gap := g.getTheme().FieldSeparator.Render() + + // if the focused field is requesting it be zoomed, only show that field. + if g.selector.Selected().Zoom() { + g.selector.Selected().WithHeight(g.height) + fields.WriteString(g.selector.Selected().View()) + } else { + g.selector.Range(func(i int, field Field) bool { + fields.WriteString(field.View()) + if i == g.selector.Index() { + offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) + } + if i < g.selector.Total()-1 { + fields.WriteString(gap) + } + return true + }) + } + + return offset, fields.String() +} + +func (g *Group) buildView() { + offset, content := g.getContent() + g.viewport.SetContent(content) + g.viewport.SetYOffset(offset) +} + +// Header renders the group's header only (no content). +func (g *Group) Header() string { + styles := g.styles() + var parts []string + if g.title != "" { + parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) + } + if g.description != "" { + parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) + } + return strings.Join(parts, "\n") +} + +// titleFooterHeight returns the height of the footer + header. +func (g *Group) titleFooterHeight() int { + h := 0 + if s := g.Header(); s != "" { + h += lipgloss.Height(s) + } + if s := g.Footer(); s != "" { + h += lipgloss.Height(s) + } + return h +} + +// rawHeight returns the full height of the group, without using a viewport. +func (g *Group) rawHeight() int { + return lipgloss.Height(g.Content()) + g.titleFooterHeight() +} + +// View renders the group. +func (g *Group) View() string { + var parts []string + if s := g.Header(); s != "" { + parts = append(parts, s) + } + parts = append(parts, g.viewport.View()) + if s := g.Footer(); s != "" { + // append an empty line, and the footer (usually the help). + parts = append(parts, "", s) + } + if len(parts) > 0 { + // Trim suffix spaces from the last part as it can accidentally + // scroll the view up on some terminals (like Apple's Terminal.app) + // when we right to the bottom rightmost corner cell. + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") + } + return strings.Join(parts, "\n") +} + +// Content renders the group's content only (no footer). +func (g *Group) Content() string { + _, content := g.getContent() + return content +} + +// Footer renders the group's footer only (no content). +func (g *Group) Footer() string { + var parts []string + errors := g.Errors() + if g.showHelp && len(errors) <= 0 { + parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) + } + if g.showErrors { + for _, err := range errors { + parts = append(parts, wrap( + g.getTheme().Focused.ErrorMessage.Render(err.Error()), + g.width, + )) + } + } + return g.styles().Base. + Render(strings.Join(parts, "\n")) +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl new file mode 100644 index 000000000..b0d1e78e7 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl @@ -0,0 +1,104 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import "charm.land/bubbles/v2/key" + +// KeyMap is the keybindings to navigate the form. +type KeyMap struct { + Quit key.Binding + + Confirm ConfirmKeyMap + Input InputKeyMap + Select SelectKeyMap + Text TextKeyMap +} + +// InputKeyMap is the keybindings for input fields. +type InputKeyMap struct { + AcceptSuggestion key.Binding + Next key.Binding + Prev key.Binding + Submit key.Binding +} + +// TextKeyMap is the keybindings for text fields. +type TextKeyMap struct { + Next key.Binding + Prev key.Binding + NewLine key.Binding + Editor key.Binding + Submit key.Binding +} + +// SelectKeyMap is the keybindings for select fields. +type SelectKeyMap struct { + Next key.Binding + Prev key.Binding + Up key.Binding + Down key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding + Left key.Binding + Right key.Binding + Filter key.Binding + SetFilter key.Binding + ClearFilter key.Binding + Submit key.Binding +} + +// ConfirmKeyMap is the keybindings for confirm fields. +type ConfirmKeyMap struct { + Next key.Binding + Prev key.Binding + Toggle key.Binding + Submit key.Binding + Accept key.Binding + Reject key.Binding +} + +// NewDefaultKeyMap returns a new default keymap. +func NewDefaultKeyMap() *KeyMap { + return &KeyMap{ + Quit: key.NewBinding(key.WithKeys("ctrl+c")), + Input: InputKeyMap{ + AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + }, + Text: TextKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), + Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), + }, + Select: SelectKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), + Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), + Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), + ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), + HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), + GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), + GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), + }, + Confirm: ConfirmKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), + Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), + Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), + }, + } +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl new file mode 100644 index 000000000..7dafc9aab --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl @@ -0,0 +1,19 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +// Option is an option for select fields. +type Option[T comparable] struct { + Key string + Value T +} + +// NewOption returns a new select option. +func NewOption[T comparable](key string, value T) Option[T] { + return Option[T]{Key: key, Value: value} +} + +// String returns the key of the option. +func (o Option[T]) String() string { + return o.Key +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl new file mode 100644 index 000000000..b6bda8549 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl @@ -0,0 +1,98 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +// selector is a helper type for selecting items. +type selector[T any] struct { + items []T + index int +} + +// newSelector creates a new item selector. +func newSelector[T any](items []T) *selector[T] { + return &selector[T]{ + items: items, + } +} + +// Append adds an item to the selector. +func (s *selector[T]) Append(item T) { + s.items = append(s.items, item) +} + +// Next moves the selector to the next item. +func (s *selector[T]) Next() { + if s.index < len(s.items)-1 { + s.index++ + } +} + +// Prev moves the selector to the previous item. +func (s *selector[T]) Prev() { + if s.index > 0 { + s.index-- + } +} + +// OnFirst returns true if the selector is on the first item. +func (s *selector[T]) OnFirst() bool { + return s.index == 0 +} + +// OnLast returns true if the selector is on the last item. +func (s *selector[T]) OnLast() bool { + return s.index == len(s.items)-1 +} + +// Selected returns the index of the current selected item. +func (s *selector[T]) Selected() T { + return s.items[s.index] +} + +// Index returns the index of the current selected item. +func (s *selector[T]) Index() int { + return s.index +} + +// Total returns the total number of items. +func (s *selector[T]) Total() int { + return len(s.items) +} + +// SetIndex sets the selected item. +func (s *selector[T]) SetIndex(i int) { + if i < 0 || i >= len(s.items) { + return + } + s.index = i +} + +// Get returns the item at the given index. +func (s *selector[T]) Get(i int) T { + return s.items[i] +} + +// Set sets the item at the given index. +func (s *selector[T]) Set(i int, item T) { + s.items[i] = item +} + +// Range iterates over the items. +// The callback function should return true to continue the iteration. +func (s *selector[T]) Range(f func(i int, item T) bool) { + for i, item := range s.items { + if !f(i, item) { + break + } + } +} + +// ReverseRange iterates over the items in reverse. +// The callback function should return true to continue the iteration. +func (s *selector[T]) ReverseRange(f func(i int, item T) bool) { + for i := len(s.items) - 1; i >= 0; i-- { + if !f(i, s.items[i]) { + break + } + } +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl new file mode 100644 index 000000000..d2ac85e10 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl @@ -0,0 +1,157 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" +) + +// Theme represents a theme for a form. +type Theme interface { + Theme(isDark bool) *Styles +} + +// ThemeFunc is a function that returns a new theme. +type ThemeFunc func(isDark bool) *Styles + +// Theme implements the Theme interface. +func (f ThemeFunc) Theme(isDark bool) *Styles { + return f(isDark) +} + +// Styles is a collection of styles for components of the form. +// Themes can be applied to a form using the WithTheme option. +type Styles struct { + Form FormStyles + Group GroupStyles + FieldSeparator lipgloss.Style + Blurred FieldStyles + Focused FieldStyles + Help help.Styles +} + +// FormStyles are the styles for a form. +type FormStyles struct { + Base lipgloss.Style +} + +// GroupStyles are the styles for a group. +type GroupStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style +} + +// FieldStyles are the styles for input fields. +type FieldStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + ErrorIndicator lipgloss.Style + ErrorMessage lipgloss.Style + + // Select styles. + SelectSelector lipgloss.Style // Selection indicator + NextIndicator lipgloss.Style + PrevIndicator lipgloss.Style + SelectedOption lipgloss.Style + UnselectedOption lipgloss.Style + + // Textinput and teatarea styles. + TextInput TextInputStyles + + // Confirm styles. + FocusedButton lipgloss.Style + BlurredButton lipgloss.Style +} + +// TextInputStyles are the styles for text inputs. +type TextInputStyles struct { + Cursor lipgloss.Style + CursorText lipgloss.Style + Placeholder lipgloss.Style + Prompt lipgloss.Style + Text lipgloss.Style +} + +const ( + buttonPaddingHorizontal = 2 + buttonPaddingVertical = 0 +) + +// ThemeBase returns a new base theme with general styles to be inherited by +// other themes. +func ThemeBase(bool) *Styles { + var t Styles + + t.Form.Base = lipgloss.NewStyle() + t.Group.Base = lipgloss.NewStyle() + t.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") + + button := lipgloss.NewStyle(). + Padding(buttonPaddingVertical, buttonPaddingHorizontal). + MarginRight(1) + + // Focused styles. + t.Focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) + t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") + t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") + t.Focused.SelectSelector = lipgloss.NewStyle().SetString("> ") + t.Focused.NextIndicator = lipgloss.NewStyle().MarginLeft(1).SetString("→") + t.Focused.PrevIndicator = lipgloss.NewStyle().MarginRight(1).SetString("←") + t.Focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) + t.Focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) + t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + t.Help = help.New().Styles + + // Blurred styles. + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return &t +} + +// themeCharm returns a new theme based on the Charm color scheme. +func themeCharm(isDark bool) *Styles { + t := ThemeBase(isDark) + lightDark := lipgloss.LightDark(isDark) + + var ( + normalFg = lightDark(lipgloss.Color("252"), lipgloss.Color("235")) + indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) + cream = lightDark(lipgloss.Color("#FFFDF5"), lipgloss.Color("#FFFDF5")) + fuchsia = lipgloss.Color("#F780E2") + green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) + red = lightDark(lipgloss.Color("#FF4672"), lipgloss.Color("#ED567A")) + ) + + t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("238")) + t.Focused.Title = t.Focused.Title.Foreground(indigo).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(fuchsia) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(fuchsia) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(fuchsia) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(normalFg) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(fuchsia) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lightDark(lipgloss.Color("237"), lipgloss.Color("252"))) + + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(green) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("238"))) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(fuchsia) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl new file mode 100644 index 000000000..7cef6fa2e --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl @@ -0,0 +1,9 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import "charm.land/lipgloss/v2" + +func wrap(s string, limit int) string { + return lipgloss.Wrap(s, limit, ",.-; ") +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index 61eb3b236..564872449 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -13,7 +13,6 @@ import ( "strings" "time" - "charm.land/huh/v2" "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -21,6 +20,7 @@ import ( "{{getGolangPackage}}/internal/config" "{{getGolangPackage}}/internal/flagutil" + "{{getGolangPackage}}/internal/forms" "{{getGolangPackage}}/internal/output" "{{getGolangPackage}}/internal/usage" ) @@ -204,7 +204,7 @@ func interceptCommand(cmd *cobra.Command) { if missing { prompter := injectedPrompter(runCmd) if prompter == nil { - prompter = huhPrompter{} + prompter = formPrompter{} } answers, err := prompter.Prompt(runCmd, fields) if err != nil { @@ -610,7 +610,7 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing huh forms, with a sensible default. +// formWidth returns the terminal width for sizing forms, with a sensible default. func formWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { @@ -620,18 +620,17 @@ func formWidth() int { } // formTheme builds the interactive prompt theme. -func formTheme() huh.Theme { - return huh.ThemeFunc(formStyles) +func formTheme() forms.Theme { + return forms.ThemeFunc(formStyles) } -func formStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func formStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := {{themeAccent}} dimmed := {{themeDimmed}} subtle := {{themeSubtle}} errColor := {{themeError}} - greenColor := {{themeSuccess}} t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) @@ -640,11 +639,8 @@ func formStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) @@ -656,8 +652,6 @@ func formStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } @@ -688,9 +682,9 @@ func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { fmt.Fprintln(os.Stderr) } -type huhPrompter struct{} +type formPrompter struct{} -func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { +func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { answers := make([]PromptAnswer, len(fields)) required := make([]int, 0, len(fields)) optionalArgs := make([]int, 0, len(fields)) @@ -740,7 +734,7 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). Description(strings.Join(names, ", ")).Value(&fillOptional))). WithWidth(width).WithTheme(theme).WithShowHelp(false) @@ -766,21 +760,21 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { return runRepeatableFlagPrompt(field, theme, width) } var answer PromptAnswer - var formField huh.Field + var formField forms.Field switch field.Kind { case "bool": if field.Required { value := false - formField = huh.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) + formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) answer.Set = true answer.Values = []string{strconv.FormatBool(value)} - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -788,10 +782,10 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return answer, nil } value := "" - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( - huh.NewOption("Yes", "true"), huh.NewOption("No", "false"), huh.NewOption("Skip (use default)", ""), + formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( + forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), ).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -799,15 +793,15 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer case "enum": value := "" - options := make([]huh.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option[string], 0, len(field.Options)+1) if !field.Required { - options = append(options, huh.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption[string]("Skip (use default)", "")) } for _, option := range field.Options { - options = append(options, huh.NewOption(option, option)) + options = append(options, forms.NewOption(option, option)) } - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -827,12 +821,12 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer } switch field.Kind { case "json": - formField = huh.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) + formField = forms.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) if validate != nil { - formField = formField.(*huh.Text).Validate(validate) + formField = formField.(*forms.Text).Validate(validate) } case "int64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -845,7 +839,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) case "float64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -858,7 +852,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) case "duration": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -871,13 +865,13 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) default: - input := huh.NewInput().Title(field.Name).Description(description).Value(&value) + input := forms.NewInput().Title(field.Name).Description(description).Value(&value) if validate != nil { input = input.Validate(validate) } formField = input } - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -887,7 +881,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -897,7 +891,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := huh.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput().Title(title).Description(description).Value(&value) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -906,7 +900,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro return nil }) } - form := huh.NewForm(huh.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } diff --git a/templates/templates/cli/config.ts b/templates/templates/cli/config.ts index 221661ce6..a08f591eb 100644 --- a/templates/templates/cli/config.ts +++ b/templates/templates/cli/config.ts @@ -80,14 +80,6 @@ function getTemplateDependencies(): Record { ecosystem: "Go", category: "runtime", }, - "charm.land/huh/v2": { - name: "charm.land/huh/v2", - version: "v2.0.3", - cpe: "cpe:2.3:a:charmbracelet:huh:*:*:*:*:*:go:*:*", - ecosystem: "Go", - category: "runtime", - condition: "interactive", - }, "charm.land/lipgloss/v2": { name: "charm.land/lipgloss/v2", version: "v2.0.6", @@ -102,7 +94,7 @@ function getTemplateDependencies(): Record { cpe: "cpe:2.3:a:charmbracelet:bubbletea:*:*:*:*:*:go:*:*", ecosystem: "Go", category: "runtime", - condition: "interactiveMode", + condition: "interactive", }, "charm.land/bubbles/v2": { name: "charm.land/bubbles/v2", @@ -110,7 +102,7 @@ function getTemplateDependencies(): Record { cpe: "cpe:2.3:a:charmbracelet:bubbles:*:*:*:*:*:go:*:*", ecosystem: "Go", category: "runtime", - condition: "interactiveMode", + condition: "interactive", }, "github.com/creack/pty": { name: "github.com/creack/pty", @@ -433,7 +425,7 @@ function getConfigFields( Required: false, DefaultValue: true, Description: - "Generate interactive auth login flows using bubbletea/huh forms.", + "Generate interactive auth login flows using bubbletea forms.", ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index 1258e86f7..d8b5dab1a 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -16,7 +16,7 @@ {{- if isInteractiveAuthEnabled -}} {{- addImport "os" -}} {{- addImport "golang.org/x/term" -}} -{{- addImport "charm.land/huh/v2" -}} +{{- addImport "internal/forms" true -}} {{- addImport "charm.land/lipgloss/v2" -}} {{- else -}} {{- addImport "bufio" -}} @@ -78,33 +78,33 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { {{- end}} accessible := formMode == interactive.FormAccessible - var groups []*huh.Group + var groups []*forms.Group {{- if hasGlobalSecurity}} - securityFields := []huh.Field{ + securityFields := []forms.Field{ {{templateAuthLoginFormFields | indent 12 | trim}} } - groups = append(groups, huh.NewGroup(securityFields...).Title("Authentication")) + groups = append(groups, forms.NewGroup(securityFields...).Title("Authentication")) {{- end}} {{- if hasGlobals}} - globalFields := []huh.Field{ + globalFields := []forms.Field{ {{templateConfigureGlobalFormFields | indent 12 | trim}} } - groups = append(groups, huh.NewGroup(globalFields...).Title("Global Parameters")) + groups = append(groups, forms.NewGroup(globalFields...).Title("Global Parameters")) {{- end}} - // Preference fields use huh.Select which loops forever on EOF in + // Preference fields use forms.Select which loops forever on EOF in // accessible mode (non-TTY). Only show them when truly interactive. {{templateConfigurePreferenceVarDeclarations | indent 8 | trim}} if !accessible { - preferenceFields := []huh.Field{ + preferenceFields := []forms.Field{ {{templateConfigurePreferenceFormFields | indent 16 | trim}} } - groups = append(groups, huh.NewGroup(preferenceFields...).Title("Preferences")) + groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) } - form := huh.NewForm(groups...). + form := forms.NewForm(groups...). WithAccessible(accessible). WithTheme(configureFormTheme()). WithWidth(configureFormWidth()). @@ -205,18 +205,17 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { {{- if isInteractiveAuthEnabled}} // configureFormTheme builds the form theme for the configure command. -func configureFormTheme() huh.Theme { - return huh.ThemeFunc(configureFormStyles) +func configureFormTheme() forms.Theme { + return forms.ThemeFunc(configureFormStyles) } -func configureFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func configureFormStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := {{themeAccent}} dimmed := {{themeDimmed}} subtle := {{themeSubtle}} errColor := {{themeError}} - greenColor := {{themeSuccess}} t.Focused.Base = t.Focused.Base. BorderLeft(true). @@ -229,11 +228,8 @@ func configureFormStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) @@ -250,13 +246,11 @@ func configureFormStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } -// configureFormWidth returns the terminal width for sizing huh forms. +// configureFormWidth returns the terminal width for sizing forms. func configureFormWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { diff --git a/templates/templates/cli/includes/dependencies.ts b/templates/templates/cli/includes/dependencies.ts index 45b87fa2c..66718edec 100644 --- a/templates/templates/cli/includes/dependencies.ts +++ b/templates/templates/cli/includes/dependencies.ts @@ -362,8 +362,10 @@ function getDefaultDependencies(): Record { // Interactive mode dependencies (Charm ecosystem) if (isInteractiveAuthEnabled() || isInteractiveModeEnabled()) { - defaultDependencies["charm.land/huh/v2"] = - deps["charm.land/huh/v2"].version; + defaultDependencies["charm.land/bubbletea/v2"] = + deps["charm.land/bubbletea/v2"].version; + defaultDependencies["charm.land/bubbles/v2"] = + deps["charm.land/bubbles/v2"].version; defaultDependencies["charm.land/lipgloss/v2"] = deps["charm.land/lipgloss/v2"].version; if ( @@ -374,12 +376,6 @@ function getDefaultDependencies(): Record { deps["github.com/creack/pty"].version; } } - if (isInteractiveModeEnabled()) { - defaultDependencies["charm.land/bubbletea/v2"] = - deps["charm.land/bubbletea/v2"].version; - defaultDependencies["charm.land/bubbles/v2"] = - deps["charm.land/bubbles/v2"].version; - } return defaultDependencies; } diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 2d3c9124c..559e0768d 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2152,21 +2152,21 @@ registerTemplateFunc( function templateConfigurePreferenceFormFields(): string { const lines: string[] = []; - lines.push(`huh.NewSelect[string]().`); + lines.push(`forms.NewSelect[string]().`); lines.push(` Title("Default output format").`); lines.push( ` Description("Choose the default response rendering format for this CLI").`, ); lines.push(` Options(`); - lines.push(` huh.NewOption("Keep current", ""),`); + lines.push(` forms.NewOption("Keep current", ""),`); lines.push( - ` huh.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, + ` forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, ); - lines.push(` huh.NewOption("pretty", "pretty"),`); - lines.push(` huh.NewOption("json", "json"),`); - lines.push(` huh.NewOption("yaml", "yaml"),`); - lines.push(` huh.NewOption("table", "table"),`); - lines.push(` huh.NewOption("toon", "toon"),`); + lines.push(` forms.NewOption("pretty", "pretty"),`); + lines.push(` forms.NewOption("json", "json"),`); + lines.push(` forms.NewOption("yaml", "yaml"),`); + lines.push(` forms.NewOption("table", "table"),`); + lines.push(` forms.NewOption("toon", "toon"),`); lines.push(` ).`); lines.push(` Value(&cfgOutputFormat),`); return lines.join("\n "); @@ -2284,7 +2284,7 @@ registerTemplateFunc( /** * Generate Go variable declarations for the auth login form. - * Each security field gets a string variable that huh binds to. + * Each security field gets a string variable that the form binds to. */ function templateAuthLoginVarDeclarations(): string { const fields = getCLISecurityFields(); @@ -2303,11 +2303,11 @@ registerTemplateFunc( ); /** - * Generate huh form field construction for the auth login form. - * Each security field is mapped to the appropriate huh component: - * - Secrets → huh.NewInput().EchoMode(huh.EchoModePassword) - * - Non-secrets → huh.NewInput() - * - Arrays → huh.NewInput() with comma-separated hint + * Generate form field construction for the auth login form. + * Each security field is mapped to the appropriate form component: + * - Secrets → forms.NewInput().EchoMode(forms.EchoModePassword) + * - Non-secrets → forms.NewInput() + * - Arrays → forms.NewInput() with comma-separated hint */ function templateAuthLoginFormFields(): string { const fields = getCLISecurityFields(); @@ -2323,7 +2323,7 @@ function templateAuthLoginFormFields(): string { const cfgExpr = `${acc}${fieldName}`; if (field.isArray) { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2334,7 +2334,7 @@ function templateAuthLoginFormFields(): string { ); lines.push(` Value(&${varName}),`); } else if (field.isSecret) { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2342,7 +2342,7 @@ function templateAuthLoginFormFields(): string { field.field, )}").`, ); - lines.push(` EchoMode(huh.EchoModePassword).`); + lines.push(` EchoMode(forms.EchoModePassword).`); lines.push( ` Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, @@ -2351,7 +2351,7 @@ function templateAuthLoginFormFields(): string { ); lines.push(` Value(&${varName}),`); } else { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2596,14 +2596,14 @@ function templateAuthLoginBody(): string { lines.push(`// #region custom-auth-vars`); lines.push(`// #endregion custom-auth-vars`); lines.push(``); - lines.push(`fields := []huh.Field{`); + lines.push(`fields := []forms.Field{`); lines.push(...genSchemeFormFields(group.fields)); lines.push(`}`); lines.push(``); lines.push(`// #region custom-auth-groups`); lines.push(`// #endregion custom-auth-groups`); lines.push(``); - lines.push(`form := huh.NewForm(huh.NewGroup(fields...)).`); + lines.push(`form := forms.NewForm(forms.NewGroup(fields...)).`); lines.push(` WithAccessible(accessible).`); lines.push(` WithTheme(authFormTheme()).`); lines.push(` WithWidth(authFormWidth()).`); @@ -2619,13 +2619,13 @@ function templateAuthLoginBody(): string { lines.push(`accessible := formMode == interactive.FormAccessible`); lines.push(``); lines.push(`var selectedScheme string`); - lines.push(`schemeSelect := huh.NewSelect[string]().`); + lines.push(`schemeSelect := forms.NewSelect[string]().`); lines.push(` Title("Authentication Method").`); lines.push(` Description("Choose which credentials to configure").`); lines.push(` Options(`); for (const group of groups) { lines.push( - ` huh.NewOption("${escapeGoString(group.label)}", "${ + ` forms.NewOption("${escapeGoString(group.label)}", "${ group.key }"),`, ); @@ -2634,7 +2634,7 @@ function templateAuthLoginBody(): string { lines.push(` Value(&selectedScheme)`); lines.push(``); lines.push( - `if err := huh.NewForm(huh.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil {`, + `if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2648,13 +2648,13 @@ function templateAuthLoginBody(): string { lines.push(`case "${group.key}":`); lines.push(...genSchemeVarDecls(group.fields).map((l) => ` ${l}`)); lines.push(``); - lines.push(` fields := []huh.Field{`); + lines.push(` fields := []forms.Field{`); lines.push( ...genSchemeFormFields(group.fields).map((l) => ` ${l}`), ); lines.push(` }`); lines.push(``); - lines.push(` form := huh.NewForm(huh.NewGroup(fields...)).`); + lines.push(` form := forms.NewForm(forms.NewGroup(fields...)).`); lines.push(` WithAccessible(accessible).`); lines.push(` WithTheme(authFormTheme()).`); lines.push(` WithWidth(authFormWidth()).`); @@ -2814,7 +2814,7 @@ function genSchemeVarDecls(fields: CLISecurityFieldInfo[]): string[] { } /** - * Build the Description() string for a huh form field. + * Build the Description() string for a form field. * Includes the flag name and appends example from the spec if available. */ function buildFormDescription( @@ -2835,7 +2835,7 @@ function buildFormDescription( } /** - * Build the Placeholder() Go expression for a huh form field. + * Build the Placeholder() Go expression for a form field. * Falls back to the field's default value when the stored config value is empty. */ function buildFormPlaceholder(configExpr: string, field: FieldDef): string { @@ -2851,7 +2851,7 @@ function buildFormPlaceholder(configExpr: string, field: FieldDef): string { return configExpr; } -/** Generate huh form field construction for a set of scheme fields */ +/** Generate form field construction for a set of scheme fields */ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { const acc = securityConfigAccessor(); const lines: string[] = []; @@ -2863,7 +2863,7 @@ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { const cfgExpr = `${acc}${fieldName}`; if (field.isArray) { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2874,7 +2874,7 @@ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { ); lines.push(` Value(&${varName}),`); } else if (field.isSecret) { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2882,7 +2882,7 @@ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { field.field, )}").`, ); - lines.push(` EchoMode(huh.EchoModePassword).`); + lines.push(` EchoMode(forms.EchoModePassword).`); lines.push( ` Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, @@ -2891,7 +2891,7 @@ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { ); lines.push(` Value(&${varName}),`); } else { - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${title}").`); lines.push( ` Description("${buildFormDescription( @@ -2951,8 +2951,8 @@ function genSchemeStore(fields: CLISecurityFieldInfo[]): string[] { registerTemplateFunc("templateAuthLoginBody", templateAuthLoginBody); -// ─── Configure Huh Form Helpers ────────────────────────────────────────────── -// These generate huh-based form fields for the configure command when +// ─── Configure Form Helpers ────────────────────────────────────────────────── +// These generate form fields for the configure command when // interactiveAuth is enabled. They cover both security AND global parameters. /** @@ -2977,7 +2977,7 @@ registerTemplateFunc( ); /** - * Generate huh form fields for global parameters. + * Generate form fields for global parameters. */ function templateConfigureGlobalFormFields(): string { if (!hasGlobals()) return ""; @@ -2996,7 +2996,7 @@ function templateConfigureGlobalFormFields(): string { `Global ${sanitizeFlagName(field.Name)} parameter`; const flagName = sanitizeFlagName(field.Name); - lines.push(`huh.NewInput().`); + lines.push(`forms.NewInput().`); lines.push(` Title("${escapeGoString(desc)}").`); lines.push(` Description("${buildFormDescription(flagName, field)}").`); lines.push(` Placeholder(${buildFormPlaceholder(cfgExpr, field)}).`); @@ -3011,7 +3011,7 @@ registerTemplateFunc( ); /** - * Generate store code for global parameters after huh form submission. + * Generate store code for global parameters after form submission. */ function templateConfigureGlobalStore(): string { if (!hasGlobals()) return ""; diff --git a/templates/templates/cli/tests/primary/configure_test.go.stmpl b/templates/templates/cli/tests/primary/configure_test.go.stmpl index de7c6efae..842204b66 100644 --- a/templates/templates/cli/tests/primary/configure_test.go.stmpl +++ b/templates/templates/cli/tests/primary/configure_test.go.stmpl @@ -166,7 +166,7 @@ func TestConfigureSkipsOptionalCredentials(t *testing.T) { output := h.GetStdout() {{- if not (isInteractiveAuthEnabled)}} - // Verify intro messages (readline mode only — huh forms don't print these) + // Verify intro messages (readline mode only — guided forms don't print these) assert.Contains(t, output, "Configure ") assert.Contains(t, output, "Enter - to clear a previously set value.") {{- end}} @@ -264,7 +264,7 @@ func TestConfigureMaskSecretDisplay(t *testing.T) { // In readline mode, existing value should be masked inline in the prompt assert.Contains(t, output, "sk******ef") {{- end}} - // In huh form mode, the masked value is used as a Placeholder which may not + // In guided form mode, the masked value is used as a Placeholder which may not // appear in accessible text output. The key assertion is that the full secret // is never printed. } diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index ba46d959c..a898b07b0 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -58,15 +58,15 @@ trackedFiles: cmd/gendocs/main_test.go: last_write_checksum: sha1:80e37af8940a8575a635b643431bb16f204209bb go.mod: - last_write_checksum: sha1:872be2b9e087d4dc3511651c8a92450a7cf5aa54 + last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:2f660d9f61e378b1f263fabd87577ca889bef180 + last_write_checksum: sha1:d34149dac5264dd61b305d979adb072174bda1c4 internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:d5ccc0970afac880e050719fe49c883432062904 internal/cli/configure.go: - last_write_checksum: sha1:81f421f92ba05428359b905815e1dd23c64e7539 + last_write_checksum: sha1:13ece24b90a03f30902ade4176a558afce3ca198 internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -249,8 +249,36 @@ trackedFiles: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a + internal/forms/accessible.go: + last_write_checksum: sha1:93ae0d80160d1ef815e1451281a4240b9a6e5c1b + internal/forms/accessor.go: + last_write_checksum: sha1:d2f23cf8fca78124e481a2f945e5e7e7bf924e5d + internal/forms/doc.go: + last_write_checksum: sha1:163e19b520eb18d3cfa405afc766a16371bb56db + internal/forms/field_confirm.go: + last_write_checksum: sha1:64e65c0371aca887fefa0c85ea7f1a921f8deabc + internal/forms/field_input.go: + last_write_checksum: sha1:3b2044da717ae5d9461d560309de742edd5f5554 + internal/forms/field_select.go: + last_write_checksum: sha1:cc9fa18cabced90d0d7bed6f4ebd3a0808aadb80 + internal/forms/field_text.go: + last_write_checksum: sha1:5ba863567bbd29d44b4af8c5ce6c98ca4134c576 + internal/forms/form.go: + last_write_checksum: sha1:7b4cccdc3ecf0f36876211afb539ef2ce7f059b1 + internal/forms/group.go: + last_write_checksum: sha1:c2f1361f63dd88876459f895885d30e6c448f8d8 + internal/forms/keymap.go: + last_write_checksum: sha1:609e9c9c2c7c4f853009642d12b845c17d484362 + internal/forms/option.go: + last_write_checksum: sha1:3669db405886fb0d4d1495089a6d1331ac781b04 + internal/forms/selector.go: + last_write_checksum: sha1:18bc62ce4de6aa3e38a663c60f289b65ae547f58 + internal/forms/theme.go: + last_write_checksum: sha1:fcb1a9bb680603c44dac9e0f272d13b437cb1c7f + internal/forms/wrap.go: + last_write_checksum: sha1:34c2c387acfbe04a76bb6e81103e5466064c02b8 internal/interactive/interactive.go: - last_write_checksum: sha1:ba6979695c50a3c2385bc74a4676dfe88814a984 + last_write_checksum: sha1:ec3c54636b52c8d0ce2b3a1b605c5309ab91eeba internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/go.mod b/zSDKs/sdk-cli/go.mod index 951a79368..148bc4b03 100644 --- a/zSDKs/sdk-cli/go.mod +++ b/zSDKs/sdk-cli/go.mod @@ -5,7 +5,6 @@ go 1.26.8 require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.0.10 - charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/alpkeskin/gotoon v0.1.1 github.com/creack/pty v1.1.24 @@ -24,12 +23,9 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect - github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -38,13 +34,11 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/zSDKs/sdk-cli/go.sum b/zSDKs/sdk-cli/go.sum index 208e56430..f026e084a 100644 --- a/zSDKs/sdk-cli/go.sum +++ b/zSDKs/sdk-cli/go.sum @@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o= charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc= -charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= -charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -16,32 +14,20 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= -github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= -github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= -github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= -github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= -github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -54,8 +40,6 @@ github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05 h1:S92OBrGuLLZsyM5ybUzgc/mPjIYk2AZqufieooe98uw= github.com/ericlagergren/decimal v0.0.0-20221120152707-495c53812d05/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -72,8 +56,6 @@ github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBql github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index 05433e8b8..dc8475e0f 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -3,7 +3,6 @@ package cli import ( - "charm.land/huh/v2" "charm.land/lipgloss/v2" "cmp" "fmt" @@ -11,6 +10,7 @@ import ( "golang.org/x/term" "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/interactive" "os" ) @@ -90,7 +90,7 @@ This removes all credentials previously set via auth login or configure.`, return nil } -// runAuthLoginCmd executes the auth login command using huh forms. +// runAuthLoginCmd executes the auth login command using guided forms. func runAuthLoginCmd(cmd *cobra.Command, args []string) error { if dryRunLocalNoop(cmd, "auth login changes local credentials only (no API request); nothing was changed.") { return nil @@ -188,21 +188,21 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { accessible := formMode == interactive.FormAccessible var selectedScheme string - schemeSelect := huh.NewSelect[string](). + schemeSelect := forms.NewSelect[string](). Title("Authentication Method"). Description("Choose which credentials to configure"). Options( - huh.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), - huh.NewOption("HTTP Bearer + API Key", "option2"), - huh.NewOption("OAuth2 Authorization", "option3"), - huh.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), - huh.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), - huh.NewOption("OAuth2 Client Credentials", "option6"), - huh.NewOption("API Key", "my-api-key"), + forms.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), + forms.NewOption("HTTP Bearer + API Key", "option2"), + forms.NewOption("OAuth2 Authorization", "option3"), + forms.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), + forms.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), + forms.NewOption("OAuth2 Client Credentials", "option6"), + forms.NewOption("API Key", "my-api-key"), ). Value(&selectedScheme) - if err := huh.NewForm(huh.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil { + if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -211,21 +211,21 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authUsername string var authPassword string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("HTTP Basic username"). Description("--username"). Placeholder(cfg.Security.Username). Value(&authUsername), - huh.NewInput(). + forms.NewInput(). Title("HTTP Basic password"). Description("--password"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). Value(&authPassword), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -249,22 +249,22 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authBearerAuth string var authMyApiKey string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). Value(&authBearerAuth), - huh.NewInput(). + forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -289,16 +289,16 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option3": var authOauth2 string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). Value(&authOauth2), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -318,22 +318,22 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authAppId string var authSecret string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). Value(&authAppId), - huh.NewInput(). + forms.NewInput(). Title("Custom authentication credential"). Description("--secret"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). Value(&authSecret), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -358,16 +358,16 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option5": var authMobileAuth string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). Value(&authMobileAuth), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -388,27 +388,27 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authClientSecret string var authTokenURL string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). Value(&authClientID), - huh.NewInput(). + forms.NewInput(). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). Value(&authClientSecret), - huh.NewInput(). + forms.NewInput(). Title("Client Credentials flow. token URL"). Description("--token-url"). Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). Value(&authTokenURL), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -437,16 +437,16 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "my-api-key": var authMyApiKey string - fields := []huh.Field{ - huh.NewInput(). + fields := []forms.Field{ + forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), } - form := huh.NewForm(huh.NewGroup(fields...)). + form := forms.NewForm(forms.NewGroup(fields...)). WithAccessible(accessible). WithTheme(authFormTheme()). WithWidth(authFormWidth()). @@ -542,18 +542,17 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { } // authFormTheme builds the form theme for auth login. -func authFormTheme() huh.Theme { - return huh.ThemeFunc(authFormStyles) +func authFormTheme() forms.Theme { + return forms.ThemeFunc(authFormStyles) } -func authFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func authFormStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := lipgloss.Color("#38BDF8") dimmed := lipgloss.Color("#64748B") subtle := lipgloss.Color("#475569") errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") t.Focused.Base = t.Focused.Base. BorderLeft(true). @@ -566,11 +565,8 @@ func authFormStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) @@ -587,13 +583,11 @@ func authFormStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } -// authFormWidth returns the terminal width for sizing huh forms. +// authFormWidth returns the terminal width for sizing forms. func authFormWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index 1431add7a..8b8e754ce 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -3,7 +3,6 @@ package cli import ( - "charm.land/huh/v2" "charm.land/lipgloss/v2" "cmp" "encoding/json" @@ -13,6 +12,7 @@ import ( "openapi/internal/client" "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/interactive" "openapi/internal/output" "openapi/internal/usage" @@ -184,121 +184,121 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { var cfgGlobalLoneQueryParam string accessible := formMode == interactive.FormAccessible - var groups []*huh.Group - securityFields := []huh.Field{ - huh.NewInput(). + var groups []*forms.Group + securityFields := []forms.Field{ + forms.NewInput(). Title("HTTP Basic username"). Description("--username"). Placeholder(cfg.Security.Username). Value(&authUsername), - huh.NewInput(). + forms.NewInput(). Title("HTTP Basic password"). Description("--password"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). Value(&authPassword), - huh.NewInput(). + forms.NewInput(). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). Value(&authBearerAuth), - huh.NewInput(). + forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), - huh.NewInput(). + forms.NewInput(). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). Value(&authOauth2), - huh.NewInput(). + forms.NewInput(). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). Value(&authAppId), - huh.NewInput(). + forms.NewInput(). Title("Custom authentication credential"). Description("--secret"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). Value(&authSecret), - huh.NewInput(). + forms.NewInput(). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). Value(&authMobileAuth), - huh.NewInput(). + forms.NewInput(). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). Value(&authClientID), - huh.NewInput(). + forms.NewInput(). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(huh.EchoModePassword). + EchoMode(forms.EchoModePassword). Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). Value(&authClientSecret), - huh.NewInput(). + forms.NewInput(). Title("Client Credentials flow. token URL"). Description("--token-url"). Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). Value(&authTokenURL), } - groups = append(groups, huh.NewGroup(securityFields...).Title("Authentication")) - globalFields := []huh.Field{ - huh.NewInput(). + groups = append(groups, forms.NewGroup(securityFields...).Title("Authentication")) + globalFields := []forms.Field{ + forms.NewInput(). Title("A long winded, multi-line description"). Description("--query-param1"). Placeholder(cfg.Globals.QueryParam1). Value(&cfgGlobalQueryParam1), - huh.NewInput(). + forms.NewInput(). Title("A deprecated description"). Description("--deprecated-query-param1"). Placeholder(cfg.Globals.DeprecatedQueryParam1). Value(&cfgGlobalDeprecatedQueryParam1), - huh.NewInput(). + forms.NewInput(). Title("Global deprecated-query-param2 parameter"). Description("--deprecated-query-param2"). Placeholder(cfg.Globals.DeprecatedQueryParam2). Value(&cfgGlobalDeprecatedQueryParam2), - huh.NewInput(). + forms.NewInput(). Title("Global lone-query-param parameter"). Description("--lone-query-param"). Placeholder(cfg.Globals.LoneQueryParam). Value(&cfgGlobalLoneQueryParam), } - groups = append(groups, huh.NewGroup(globalFields...).Title("Global Parameters")) + groups = append(groups, forms.NewGroup(globalFields...).Title("Global Parameters")) - // Preference fields use huh.Select which loops forever on EOF in + // Preference fields use forms.Select which loops forever on EOF in // accessible mode (non-TTY). Only show them when truly interactive. var cfgOutputFormat string if !accessible { - preferenceFields := []huh.Field{ - huh.NewSelect[string](). + preferenceFields := []forms.Field{ + forms.NewSelect[string](). Title("Default output format"). Description("Choose the default response rendering format for this CLI"). Options( - huh.NewOption("Keep current", ""), - huh.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), - huh.NewOption("pretty", "pretty"), - huh.NewOption("json", "json"), - huh.NewOption("yaml", "yaml"), - huh.NewOption("table", "table"), - huh.NewOption("toon", "toon"), + forms.NewOption("Keep current", ""), + forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), + forms.NewOption("pretty", "pretty"), + forms.NewOption("json", "json"), + forms.NewOption("yaml", "yaml"), + forms.NewOption("table", "table"), + forms.NewOption("toon", "toon"), ). Value(&cfgOutputFormat), } - groups = append(groups, huh.NewGroup(preferenceFields...).Title("Preferences")) + groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) } - form := huh.NewForm(groups...). + form := forms.NewForm(groups...). WithAccessible(accessible). WithTheme(configureFormTheme()). WithWidth(configureFormWidth()). @@ -429,18 +429,17 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { } // configureFormTheme builds the form theme for the configure command. -func configureFormTheme() huh.Theme { - return huh.ThemeFunc(configureFormStyles) +func configureFormTheme() forms.Theme { + return forms.ThemeFunc(configureFormStyles) } -func configureFormStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func configureFormStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := lipgloss.Color("#38BDF8") dimmed := lipgloss.Color("#64748B") subtle := lipgloss.Color("#475569") errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") t.Focused.Base = t.Focused.Base. BorderLeft(true). @@ -453,11 +452,8 @@ func configureFormStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) @@ -474,13 +470,11 @@ func configureFormStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } -// configureFormWidth returns the terminal width for sizing huh forms. +// configureFormWidth returns the terminal width for sizing forms. func configureFormWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { diff --git a/zSDKs/sdk-cli/internal/forms/accessible.go b/zSDKs/sdk-cli/internal/forms/accessible.go new file mode 100644 index 000000000..928e006ca --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/accessible.go @@ -0,0 +1,182 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "bufio" + "cmp" + "errors" + "fmt" + "io" + "slices" + "strconv" + "strings" + + "golang.org/x/term" +) + +func atoi(s string) (int, error) { + if strings.TrimSpace(s) == "" { + return -1, nil + } + return strconv.Atoi(s) +} + +// promptInt prompts a user for an integer between a certain range. +// +// Given invalid input (non-integers, integers outside of the range), the user +// will continue to be reprompted until a valid input is given, ensuring that +// the return value is always valid. +func promptInt( + out io.Writer, + in io.Reader, + prompt string, + low, high int, + defaultValue *int, +) int { + var choice int + + validInt := func(s string) error { + if strings.TrimSpace(s) == "" && defaultValue != nil { + return nil + } + i, err := atoi(s) + if err != nil || i < low || i > high { + if low == high { + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be %d", low) + } + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) + } + return nil + } + + input := promptString( + out, + in, + prompt, + ptrToStr(defaultValue, strconv.Itoa), + validInt, + ) + choice, _ = strconv.Atoi(input) + return choice +} + +func parseBool(s string) (bool, error) { + s = strings.ToLower(s) + + if slices.Contains([]string{"y", "yes"}, s) { + return true, nil + } + + // As a special case, we default to "" to no since the usage of this + // function suggests N is the default. + if slices.Contains([]string{"n", "no"}, s) { + return false, nil + } + + return false, errors.New("invalid input. please try again") +} + +// promptBool prompts a user for a boolean value. +// +// Given invalid input (non-boolean), the user will continue to be reprompted +// until a valid input is given, ensuring that the return value is always valid. +func promptBool( + out io.Writer, + in io.Reader, + prompt string, + defaultValue bool, +) bool { + validBool := func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + _, err := parseBool(s) + return err + } + + input := promptString( + out, in, prompt, + boolToStr(defaultValue), + validBool, + ) + b, _ := parseBool(input) + return b +} + +// promptPassword allows to prompt for a password. +// In must be the fd of a tty. +func promptPassword( + out io.Writer, + in uintptr, + prompt string, + validator func(input string) error, +) (string, error) { + for { + _, _ = fmt.Fprint(out, prompt) + pwd, err := term.ReadPassword(int(in)) + if err != nil { + return "", err + } + _, _ = fmt.Fprintln(out) + if err := validator(string(pwd)); err != nil { + _, _ = fmt.Fprintln(out, err) + continue + } + return string(pwd), nil + } +} + +// promptString prompts a user for a string value and validates it against a +// validator function. It re-prompts the user until a valid input is given. +func promptString( + out io.Writer, + in io.Reader, + prompt string, + defaultValue string, + validator func(input string) error, +) string { + scanner := bufio.NewScanner(in) + + var ( + valid bool + input string + ) + + for !valid { + _, _ = fmt.Fprint(out, prompt) + if !scanner.Scan() { + // no way to bubble up errors or signal cancellation + // but the program is probably not continuing if + // stdin sent EOF + _, _ = fmt.Fprintln(out) + break + } + input = scanner.Text() + + if err := validator(input); err != nil { + _, _ = fmt.Fprintln(out, err) + continue + } + + break + } + + return cmp.Or(strings.TrimSpace(input), defaultValue) +} + +func ptrToStr[T any](t *T, fn func(t T) string) string { + if t == nil { + return "" + } + return fn(*t) +} + +func boolToStr(b bool) string { + if b { + return "y" + } + return "N" +} diff --git a/zSDKs/sdk-cli/internal/forms/accessor.go b/zSDKs/sdk-cli/internal/forms/accessor.go new file mode 100644 index 000000000..53274d8e2 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/accessor.go @@ -0,0 +1,46 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +// Accessor give read/write access to field values. +type Accessor[T any] interface { + Get() T + Set(value T) +} + +// EmbeddedAccessor is a basic accessor, acting as the default one for fields. +type EmbeddedAccessor[T any] struct { + value T +} + +// Get gets the value. +func (a *EmbeddedAccessor[T]) Get() T { + return a.value +} + +// Set sets the value. +func (a *EmbeddedAccessor[T]) Set(value T) { + a.value = value +} + +// PointerAccessor allows field value to be exposed as a pointed variable. +type PointerAccessor[T any] struct { + value *T +} + +// NewPointerAccessor returns a new pointer accessor. +func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { + return &PointerAccessor[T]{ + value: value, + } +} + +// Get gets the value. +func (a *PointerAccessor[T]) Get() T { + return *a.value +} + +// Set sets the value. +func (a *PointerAccessor[T]) Set(value T) { + *a.value = value +} diff --git a/zSDKs/sdk-cli/internal/forms/doc.go b/zSDKs/sdk-cli/internal/forms/doc.go new file mode 100644 index 000000000..07c2f280a --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/doc.go @@ -0,0 +1,31 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +// MIT License +// +// Copyright (c) 2023–2026 Charm +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Package forms renders the terminal forms used by guided prompts: text +// inputs, multi-line text, selects, and confirmations, with an accessible +// line-prompt mode for screen readers and non-TTY input. +// +// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), +// reduced to the fields and form options this CLI uses. +package forms diff --git a/zSDKs/sdk-cli/internal/forms/field_confirm.go b/zSDKs/sdk-cli/internal/forms/field_confirm.go new file mode 100644 index 000000000..325d28b7d --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/field_confirm.go @@ -0,0 +1,308 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "cmp" + "io" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Confirm is a form confirm field. +type Confirm struct { + accessor Accessor[bool] + + // customization + title string + description string + affirmative string + negative string + + // error handling + validate func(bool) error + err error + + // state + focused bool + + // options + width int + height int + inline bool + theme Theme + hasDarkBg bool + keymap ConfirmKeyMap + buttonAlignment lipgloss.Position +} + +// NewConfirm returns a new confirm field. +func NewConfirm() *Confirm { + return &Confirm{ + accessor: &EmbeddedAccessor[bool]{}, + affirmative: "Yes", + negative: "No", + validate: func(bool) error { return nil }, + buttonAlignment: lipgloss.Center, + } +} + +// Validate sets the validation function of the confirm field. +func (c *Confirm) Validate(validate func(bool) error) *Confirm { + c.validate = validate + return c +} + +// Error returns the error of the confirm field. +func (c *Confirm) Error() error { + return c.err +} + +// Skip returns whether the confirm should be skipped or should be blocking. +func (*Confirm) Skip() bool { + return false +} + +// Zoom returns whether the input should be zoomed. +func (*Confirm) Zoom() bool { + return false +} + +// Affirmative sets the affirmative value of the confirm field. +func (c *Confirm) Affirmative(affirmative string) *Confirm { + c.affirmative = affirmative + return c +} + +// Negative sets the negative value of the confirm field. +func (c *Confirm) Negative(negative string) *Confirm { + c.negative = negative + return c +} + +// Value sets the value of the confirm field. +func (c *Confirm) Value(value *bool) *Confirm { + return c.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the confirm field. +func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { + c.accessor = accessor + return c +} + +// Title sets the title of the confirm field. +func (c *Confirm) Title(title string) *Confirm { + c.title = title + return c +} + +// Description sets the description of the confirm field. +func (c *Confirm) Description(description string) *Confirm { + c.description = description + return c +} + +// Inline sets whether the field should be inline. +func (c *Confirm) Inline(inline bool) *Confirm { + c.inline = inline + return c +} + +// Focus focuses the confirm field. +func (c *Confirm) Focus() tea.Cmd { + c.focused = true + return nil +} + +// Blur blurs the confirm field. +func (c *Confirm) Blur() tea.Cmd { + c.focused = false + c.err = c.validate(c.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the confirm field. +func (c *Confirm) KeyBinds() []key.Binding { + return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} +} + +// Init initializes the confirm field. +func (c *Confirm) Init() tea.Cmd { + return nil +} + +// Update updates the confirm field. +func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + c.hasDarkBg = msg.IsDark() + case tea.KeyPressMsg: + c.err = nil + switch { + case key.Matches(msg, c.keymap.Toggle): + if c.negative == "" { + break + } + c.accessor.Set(!c.accessor.Get()) + case key.Matches(msg, c.keymap.Prev): + cmds = append(cmds, PrevField) + case key.Matches(msg, c.keymap.Next, c.keymap.Submit): + cmds = append(cmds, NextField) + case key.Matches(msg, c.keymap.Accept): + c.accessor.Set(true) + cmds = append(cmds, NextField) + case key.Matches(msg, c.keymap.Reject): + c.accessor.Set(false) + cmds = append(cmds, NextField) + } + } + + return c, tea.Batch(cmds...) +} + +func (c *Confirm) activeStyles() *FieldStyles { + theme := c.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if c.focused { + return &theme.Theme(c.hasDarkBg).Focused + } + return &theme.Theme(c.hasDarkBg).Blurred +} + +// View renders the confirm field. +func (c *Confirm) View() string { + styles := c.activeStyles() + maxWidth := c.width - styles.Base.GetHorizontalFrameSize() + + var wroteHeader bool + var sb strings.Builder + if c.title != "" { + sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) + wroteHeader = true + } + if c.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + wroteHeader = true + } + + if c.description != "" { + description := styles.Description.Render(wrap(c.description, maxWidth)) + if !c.inline && c.description != "" { + sb.WriteString("\n") + } + sb.WriteString(description) + wroteHeader = true + } + + if !c.inline && wroteHeader { + sb.WriteString("\n") + sb.WriteString("\n") + } + + var negative string + var affirmative string + if c.negative != "" { + if c.accessor.Get() { + affirmative = styles.FocusedButton.Render(c.affirmative) + negative = styles.BlurredButton.Render(c.negative) + } else { + affirmative = styles.BlurredButton.Render(c.affirmative) + negative = styles.FocusedButton.Render(c.negative) + } + c.keymap.Reject.SetHelp("n", c.negative) + } else { + affirmative = styles.FocusedButton.Render(c.affirmative) + c.keymap.Reject.SetEnabled(false) + } + + c.keymap.Accept.SetHelp("y", c.affirmative) + + buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) + + promptWidth := lipgloss.Width(sb.String()) + buttonsWidth := lipgloss.Width(buttonsRow) + + renderWidth := max(buttonsWidth, promptWidth) + + style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) + + sb.WriteString(style.Render(buttonsRow)) + return styles.Base.Width(c.width).Height(c.height). + Render(sb.String()) +} + +// RunAccessible runs the confirm field in accessible mode. +func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { + styles := c.activeStyles() + defaultValue := c.GetValue().(bool) + opts := "[y/N]" + if defaultValue { + opts = "[Y/n]" + } + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(c.title, "Choose"), opts) + c.accessor.Set(promptBool(w, r, prompt, defaultValue)) + return nil +} + +func (c *Confirm) String() string { + if c.accessor.Get() { + return c.affirmative + } + return c.negative +} + +// WithTheme sets the theme of the confirm field. +func (c *Confirm) WithTheme(theme Theme) Field { + if c.theme != nil { + return c + } + c.theme = theme + return c +} + +// WithKeyMap sets the keymap of the confirm field. +func (c *Confirm) WithKeyMap(k *KeyMap) Field { + c.keymap = k.Confirm + return c +} + +// WithWidth sets the width of the confirm field. +func (c *Confirm) WithWidth(width int) Field { + c.width = width + return c +} + +// WithHeight sets the height of the confirm field. +func (c *Confirm) WithHeight(height int) Field { + c.height = height + return c +} + +// WithPosition sets the position of the confirm field. +func (c *Confirm) WithPosition(p FieldPosition) Field { + c.keymap.Prev.SetEnabled(!p.IsFirst()) + c.keymap.Next.SetEnabled(!p.IsLast()) + c.keymap.Submit.SetEnabled(p.IsLast()) + return c +} + +// WithButtonAlignment sets the button position of the confirm field. +func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { + c.buttonAlignment = p + return c +} + +// GetValue returns the value of the field. +func (c *Confirm) GetValue() any { + return c.accessor.Get() +} diff --git a/zSDKs/sdk-cli/internal/forms/field_input.go b/zSDKs/sdk-cli/internal/forms/field_input.go new file mode 100644 index 000000000..049323167 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/field_input.go @@ -0,0 +1,345 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "cmp" + "errors" + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Input is a input field. +// +// The input field is a field that allows the user to enter text. Use it to user +// input. It can be used for collecting text, passwords, or other short input. +// +// The input field supports Suggestions, Placeholder, and Validation. +type Input struct { + accessor Accessor[string] + + title string + description string + + textinput textinput.Model + + inline bool + validate func(string) error + err error + focused bool + + width int + height int + + theme Theme + hasDarkBg bool + keymap InputKeyMap +} + +// NewInput creates a new input field. +// +// The input field is a field that allows the user to enter text. Use it to user +// input. It can be used for collecting text, passwords, or other short input. +// +// The input field supports Suggestions, Placeholder, and Validation. +func NewInput() *Input { + input := textinput.New() + + i := &Input{ + accessor: &EmbeddedAccessor[string]{}, + textinput: input, + validate: func(string) error { return nil }, + } + + return i +} + +// Value sets the value of the input field. +func (i *Input) Value(value *string) *Input { + return i.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the input field. +func (i *Input) Accessor(accessor Accessor[string]) *Input { + i.accessor = accessor + i.textinput.SetValue(i.accessor.Get()) + return i +} + +// Title sets the title of the input field. +func (i *Input) Title(title string) *Input { + i.title = title + return i +} + +// Description sets the description of the input field. +func (i *Input) Description(description string) *Input { + i.description = description + return i +} + +// Prompt sets the prompt of the input field. +func (i *Input) Prompt(prompt string) *Input { + i.textinput.Prompt = prompt + return i +} + +// CharLimit sets the character limit of the input field. +func (i *Input) CharLimit(charlimit int) *Input { + i.textinput.CharLimit = charlimit + return i +} + +// EchoMode sets the input behavior of the text Input field. +type EchoMode textinput.EchoMode + +const ( + // EchoModeNormal displays text as is. + // This is the default behavior. + EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal) + + // EchoModePassword displays the EchoCharacter mask instead of actual characters. + // This is commonly used for password fields. + EchoModePassword EchoMode = EchoMode(textinput.EchoPassword) + + // EchoModeNone displays nothing as characters are entered. + // This is commonly seen for password fields on the command line. + EchoModeNone EchoMode = EchoMode(textinput.EchoNone) +) + +// EchoMode sets the echo mode of the input. +func (i *Input) EchoMode(mode EchoMode) *Input { + i.textinput.EchoMode = textinput.EchoMode(mode) + return i +} + +// Placeholder sets the placeholder of the text input. +func (i *Input) Placeholder(str string) *Input { + i.textinput.Placeholder = str + return i +} + +// Inline sets whether the title and input should be on the same line. +func (i *Input) Inline(inline bool) *Input { + i.inline = inline + return i +} + +// Validate sets the validation function of the input field. +func (i *Input) Validate(validate func(string) error) *Input { + i.validate = validate + return i +} + +// Error returns the error of the input field. +func (i *Input) Error() error { return i.err } + +// Skip returns whether the input should be skipped or should be blocking. +func (*Input) Skip() bool { return false } + +// Zoom returns whether the input should be zoomed. +func (*Input) Zoom() bool { return false } + +// Focus focuses the input field. +func (i *Input) Focus() tea.Cmd { + i.focused = true + return i.textinput.Focus() +} + +// Blur blurs the input field. +func (i *Input) Blur() tea.Cmd { + i.focused = false + i.accessor.Set(i.textinput.Value()) + i.textinput.Blur() + i.err = i.validate(i.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the input field. +func (i *Input) KeyBinds() []key.Binding { + if i.textinput.ShowSuggestions { + return []key.Binding{i.keymap.AcceptSuggestion, i.keymap.Prev, i.keymap.Submit, i.keymap.Next} + } + return []key.Binding{i.keymap.Prev, i.keymap.Submit, i.keymap.Next} +} + +// Init initializes the input field. +func (i *Input) Init() tea.Cmd { + i.textinput.Blur() + return nil +} + +// Update updates the input field. +func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + i.hasDarkBg = msg.IsDark() + case updateFieldMsg: + return i, nil + case tea.KeyPressMsg: + i.err = nil + + switch { + case key.Matches(msg, i.keymap.Prev): + cmds = append(cmds, PrevField) + case key.Matches(msg, i.keymap.Next, i.keymap.Submit): + value := i.textinput.Value() + i.err = i.validate(value) + if i.err != nil { + return i, nil + } + cmds = append(cmds, NextField) + } + } + + i.textinput, cmd = i.textinput.Update(msg) + cmds = append(cmds, cmd) + i.accessor.Set(i.textinput.Value()) + + return i, tea.Batch(cmds...) +} + +func (i *Input) activeStyles() *FieldStyles { + theme := i.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if i.focused { + return &theme.Theme(i.hasDarkBg).Focused + } + return &theme.Theme(i.hasDarkBg).Blurred +} + +// View renders the input field. +func (i *Input) View() string { + styles := i.activeStyles() + maxWidth := i.width - styles.Base.GetHorizontalFrameSize() + + // NB: since the method is on a pointer receiver these are being mutated. + // Because this runs on every render this shouldn't matter in practice, + // however. + st := i.textinput.Styles() + st.Cursor.Color = styles.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.Text = styles.TextInput.Text + st.Focused.Placeholder = styles.TextInput.Placeholder + i.textinput.SetStyles(st) + + // Adjust text input size to its char limit if it fit in its width + if i.textinput.CharLimit > 0 { + i.textinput.SetWidth(max(min(i.textinput.CharLimit, i.textinput.Width(), maxWidth), 0)) + } + + var sb strings.Builder + if i.title != "" { + sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth))) + if !i.inline { + sb.WriteString("\n") + } + } + if i.description != "" { + sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth))) + if !i.inline { + sb.WriteString("\n") + } + } + sb.WriteString(i.textinput.View()) + + return styles.Base. + Width(i.width). + Height(i.height). + Render(sb.String()) +} + +// RunAccessible runs the input field in accessible mode. +func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { + styles := i.activeStyles() + validator := func(input string) error { + if i.textinput.CharLimit > 0 && len(input) > i.textinput.CharLimit { + return fmt.Errorf("Input cannot exceed %d characters", i.textinput.CharLimit) + } + return i.validate(input) + } + + switch i.textinput.EchoMode { + case textinput.EchoNormal: + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(i.title, "Input:")) + value := promptString(w, r, prompt, i.GetValue().(string), validator) + i.accessor.Set(value) + return nil + default: + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(i.title, "Password:")) + if fd, ok := r.(interface{ Fd() uintptr }); ok { + value, err := promptPassword(w, fd.Fd(), prompt, validator) + if err != nil { + return err + } + i.accessor.Set(value) + return nil + } + return errors.New("password asking needs a tty") + } +} + +// WithKeyMap sets the keymap on an input field. +func (i *Input) WithKeyMap(k *KeyMap) Field { + i.keymap = k.Input + i.textinput.KeyMap.AcceptSuggestion = i.keymap.AcceptSuggestion + return i +} + +// WithTheme sets the theme of the input field. +func (i *Input) WithTheme(theme Theme) Field { + if i.theme != nil { + return i + } + i.theme = theme + return i +} + +// WithWidth sets the width of the input field. +func (i *Input) WithWidth(width int) Field { + styles := i.activeStyles() + i.width = width + frameSize := styles.Base.GetHorizontalFrameSize() + promptWidth := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) + titleWidth := lipgloss.Width(styles.Title.Render(i.title)) + descriptionWidth := lipgloss.Width(styles.Description.Render(i.description)) + i.textinput.SetWidth(width - frameSize - promptWidth - 1) + if i.inline { + i.textinput.SetWidth(i.textinput.Width() - titleWidth - descriptionWidth) + } + return i +} + +// WithHeight sets the height of the input field. +func (i *Input) WithHeight(height int) Field { + i.height = height + return i +} + +// WithPosition sets the position of the input field. +func (i *Input) WithPosition(p FieldPosition) Field { + i.keymap.Prev.SetEnabled(!p.IsFirst()) + i.keymap.Next.SetEnabled(!p.IsLast()) + i.keymap.Submit.SetEnabled(p.IsLast()) + return i +} + +// GetValue returns the value of the field. +func (i *Input) GetValue() any { + return i.accessor.Get() +} diff --git a/zSDKs/sdk-cli/internal/forms/field_select.go b/zSDKs/sdk-cli/internal/forms/field_select.go new file mode 100644 index 000000000..5bb758b36 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/field_select.go @@ -0,0 +1,680 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "cmp" + "fmt" + "io" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +const ( + minHeight = 1 + defaultHeight = 10 +) + +// Select is a select field. +// +// A select field is a field that allows the user to select from a list of +// options. The options can be provided statically or dynamically using Options +// or OptionsFunc. The options can be filtered using "/" and navigation is done +// using j/k, up/down, or ctrl+n/ctrl+p keys. +type Select[T comparable] struct { + accessor Accessor[T] + + viewport viewport.Model + + title string + description string + options []Option[T] + filteredOptions []Option[T] + + validate func(T) error + err error + + selected int + focused bool + filtering bool + filter textinput.Model + + inline bool + width int + height int + theme Theme + hasDarkBg bool + keymap SelectKeyMap +} + +// NewSelect creates a new select field. +// +// A select field is a field that allows the user to select from a list of +// options. The options can be provided statically or dynamically using Options +// or OptionsFunc. The options can be filtered using "/" and navigation is done +// using j/k, up/down, or ctrl+n/ctrl+p keys. +func NewSelect[T comparable]() *Select[T] { + filter := textinput.New() + filter.Prompt = "/" + + return &Select[T]{ + accessor: &EmbeddedAccessor[T]{}, + validate: func(T) error { return nil }, + filtering: false, + filter: filter, + } +} + +// Value sets the value of the select field. +func (s *Select[T]) Value(value *T) *Select[T] { + return s.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the select field. +func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] { + s.accessor = accessor + s.selectValue(s.accessor.Get()) + s.updateValue() + return s +} + +func (s *Select[T]) selectValue(value T) { + for i, o := range s.options { + if o.Value == value { + s.selected = i + break + } + } +} + +// Title sets the title of the select field. +func (s *Select[T]) Title(title string) *Select[T] { + s.title = title + return s +} + +// Filtering sets the filtering state of the select field. +func (s *Select[T]) Filtering(filtering bool) *Select[T] { + s.filtering = filtering + s.filter.Focus() + return s +} + +// Description sets the description of the select field. +func (s *Select[T]) Description(description string) *Select[T] { + s.description = description + return s +} + +// Options sets the options of the select field. +// +// This is what your user will select from. +// +// Title +// Description +// +// -> Option 1 +// Option 2 +// Option 3 +func (s *Select[T]) Options(options ...Option[T]) *Select[T] { + if len(options) <= 0 { + return s + } + s.options = options + s.filteredOptions = options + + s.selectOption() + + s.updateViewportSize() + s.updateValue() + + return s +} + +func (s *Select[T]) selectOption() { + // Set the cursor to the existing value. + for i, option := range s.options { + if option.Value == s.accessor.Get() { + s.selected = i + break + } + } + s.ensureCursorVisible() +} + +// Inline sets whether the select input should be inline. +func (s *Select[T]) Inline(v bool) *Select[T] { + s.inline = v + if v { + s.Height(1) + } + s.keymap.Left.SetEnabled(v) + s.keymap.Right.SetEnabled(v) + s.keymap.Up.SetEnabled(!v) + s.keymap.Down.SetEnabled(!v) + return s +} + +// Height sets the height of the select field. If the number of options exceeds +// the height, the select field will become scrollable. +func (s *Select[T]) Height(height int) *Select[T] { + s.height = height + s.updateViewportSize() + return s +} + +// Validate sets the validation function of the select field. +func (s *Select[T]) Validate(validate func(T) error) *Select[T] { + s.validate = validate + return s +} + +// Error returns the error of the select field. +func (s *Select[T]) Error() error { return s.err } + +// Skip returns whether the select should be skipped or should be blocking. +func (*Select[T]) Skip() bool { return false } + +// Zoom returns whether the input should be zoomed. +func (*Select[T]) Zoom() bool { return false } + +// Focus focuses the select field. +func (s *Select[T]) Focus() tea.Cmd { + s.focused = true + return nil +} + +// Blur blurs the select field. +func (s *Select[T]) Blur() tea.Cmd { + value := s.accessor.Get() + if s.inline { + s.clearFilter() + s.selectValue(value) + } + s.focused = false + s.err = s.validate(value) + return nil +} + +// Hovered returns the value of the option under the cursor, and a bool +// indicating whether one was found. If there are no visible options, returns +// a zero-valued T and false. +func (s *Select[T]) Hovered() (T, bool) { + if len(s.filteredOptions) == 0 || s.selected >= len(s.filteredOptions) { + var zero T + return zero, false + } + return s.filteredOptions[s.selected].Value, true +} + +// KeyBinds returns the help keybindings for the select field. +func (s *Select[T]) KeyBinds() []key.Binding { + return []key.Binding{ + s.keymap.Up, + s.keymap.Down, + s.keymap.Left, + s.keymap.Right, + s.keymap.Filter, + s.keymap.SetFilter, + s.keymap.ClearFilter, + s.keymap.Prev, + s.keymap.Next, + s.keymap.Submit, + } +} + +// Init initializes the select field. +func (s *Select[T]) Init() tea.Cmd { + return nil +} + +// Update updates the select field. +func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) { + s.updateViewportSize() + + var cmd tea.Cmd + if s.filtering { + s.filter, cmd = s.filter.Update(msg) + } + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + s.hasDarkBg = msg.IsDark() + case updateFieldMsg: + return s, nil + + case tea.KeyPressMsg: + s.err = nil + switch { + case key.Matches(msg, s.keymap.Filter): + s.setFiltering(true) + return s, s.filter.Focus() + case key.Matches(msg, s.keymap.SetFilter): + if len(s.filteredOptions) <= 0 { + s.filter.SetValue("") + s.filteredOptions = s.options + } + s.setFiltering(false) + case key.Matches(msg, s.keymap.ClearFilter): + s.clearFilter() + case key.Matches(msg, s.keymap.Up, s.keymap.Left): + // When filtering we should ignore j/k keybindings + // + // XXX: Currently, the below check doesn't account for keymap + // changes. When making this fix it's worth considering ignoring + // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 + // may not be enough when international keyboards are considered. + if s.filtering && (msg.String() == "k" || msg.String() == "h") { + break + } + s.selected = s.selected - 1 + if s.selected < 0 { + s.selected = len(s.filteredOptions) - 1 + s.viewport.GotoBottom() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msg, s.keymap.GotoTop): + if s.filtering { + break + } + s.selected = 0 + s.viewport.GotoTop() + s.updateValue() + case key.Matches(msg, s.keymap.GotoBottom): + if s.filtering { + break + } + s.selected = len(s.filteredOptions) - 1 + s.viewport.GotoBottom() + case key.Matches(msg, s.keymap.HalfPageUp): + s.selected = max(s.selected-s.viewport.Height()/2, 0) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msg, s.keymap.HalfPageDown): + s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msg, s.keymap.Down, s.keymap.Right): + // When filtering we should ignore j/k keybindings + // + // XXX: See note in the previous case match. + if s.filtering && (msg.String() == "j" || msg.String() == "l") { + break + } + s.selected = s.selected + 1 + if s.selected > len(s.filteredOptions)-1 { + s.selected = 0 + s.viewport.GotoTop() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msg, s.keymap.Prev): + if s.selected >= len(s.filteredOptions) { + break + } + s.updateValue() + s.err = s.validate(s.accessor.Get()) + if s.err != nil { + return s, nil + } + s.updateValue() + return s, PrevField + case key.Matches(msg, s.keymap.Next, s.keymap.Submit): + if s.selected >= len(s.filteredOptions) { + break + } + s.setFiltering(false) + s.updateValue() + s.err = s.validate(s.accessor.Get()) + if s.err != nil { + return s, nil + } + s.updateValue() + return s, NextField + } + + if s.filtering { + s.filteredOptions = s.options + if s.filter.Value() != "" { + s.filteredOptions = nil + for _, option := range s.options { + if s.filterFunc(option.Key) { + s.filteredOptions = append(s.filteredOptions, option) + } + } + } + if len(s.filteredOptions) > 0 { + s.selected = min(s.selected, len(s.filteredOptions)-1) + } + } + + s.ensureCursorVisible() + } + + return s, cmd +} + +func (s *Select[T]) updateValue() { + if s.selected < len(s.filteredOptions) && s.selected >= 0 { + s.accessor.Set(s.filteredOptions[s.selected].Value) + } +} + +// updateViewportSize updates the viewport size according to the Height setting +// on this select field. +func (s *Select[T]) updateViewportSize() { + if s.height > 0 { + yoffset := 0 + if ss := s.titleView(); ss != "" { + yoffset += lipgloss.Height(ss) + } + if ss := s.descriptionView(); ss != "" { + yoffset += lipgloss.Height(ss) + } + s.viewport.SetHeight(max(minHeight, s.height-yoffset)) + s.ensureCursorVisible() + } else { + // If no height is set size the viewport to the number of options. + v, _, _ := s.optionsView() + s.viewport.SetHeight(lipgloss.Height(v)) + } + if s.width > 0 { + s.viewport.SetWidth(s.width) + } else { + v, _, _ := s.optionsView() + s.viewport.SetWidth(lipgloss.Width(v)) + } +} + +func (s *Select[T]) activeStyles() *FieldStyles { + theme := s.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if s.focused { + return &theme.Theme(s.hasDarkBg).Focused + } + return &theme.Theme(s.hasDarkBg).Blurred +} + +func (s *Select[T]) titleView() string { + var ( + styles = s.activeStyles() + sb = strings.Builder{} + maxWidth = s.width - styles.Base.GetHorizontalFrameSize() + ) + if s.filtering { + sb.WriteString(s.filter.View()) + } else if s.filter.Value() != "" && !s.inline { + sb.WriteString(styles.Description.Render("/" + s.filter.Value())) + } else { + sb.WriteString(styles.Title.Render(wrap(s.title, maxWidth))) + } + if s.err != nil { + sb.WriteString(styles.ErrorIndicator.String()) + } + return sb.String() +} + +func (s *Select[T]) descriptionView() string { + if s.description == "" { + return "" + } + maxWidth := s.width - s.activeStyles().Base.GetHorizontalFrameSize() + return s.activeStyles().Description.Render(wrap(s.description, maxWidth)) +} + +func (s *Select[T]) optionsView() (string, int, int) { + var ( + styles = s.activeStyles() + sb strings.Builder + ) + + if s.inline { + option := styles.TextInput.Placeholder.Render("No matches") + if len(s.filteredOptions) > 0 { + option = styles.SelectedOption.Render(s.filteredOptions[s.selected].Key) + } + return lipgloss.NewStyle(). + Width(s.width). + Render(lipgloss.JoinHorizontal( + lipgloss.Left, + styles.PrevIndicator.Faint(s.selected <= 0).String(), + option, + styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String(), + )), + -1, 1 + } + + var cursorOffset int + var cursorHeight int + for i, option := range s.filteredOptions { + selected := s.selected == i + line := s.renderOption(option, selected) + if i < s.selected { + cursorOffset += lipgloss.Height(line) + } + if selected { + cursorHeight = lipgloss.Height(line) + } + + sb.WriteString(line) + if i < len(s.options)-1 { + sb.WriteString("\n") + } + } + + for i := len(s.filteredOptions); i < len(s.options)-1; i++ { + sb.WriteString("\n") + } + + return sb.String(), cursorOffset, cursorHeight +} + +// cursorLineOffset computes the line offset and height (in lines) for the +// currently selected option without rendering the full options string. +func (s *Select[T]) cursorLineOffset() (offset int, height int) { + for i, option := range s.filteredOptions { + line := s.renderOption(option, s.selected == i) + h := lipgloss.Height(line) + if i < s.selected { + offset += h + } + if i == s.selected { + height = h + return offset, height + } + } + return offset, height +} + +// ensureVisible scrolls a viewport the minimum amount so that the region +// [offset, offset+height) is within the visible area. +func ensureVisible(vp *viewport.Model, offset, height int) { + if height <= 0 { + return + } + yOff := vp.YOffset() + vHeight := vp.Height() + if offset < yOff { + vp.ScrollUp(yOff - offset) + } else if offset+height > yOff+vHeight { + vp.ScrollDown(offset + height - yOff - vHeight) + } +} + +func (s *Select[T]) ensureCursorVisible() { + offset, height := s.cursorLineOffset() + ensureVisible(&s.viewport, offset, height) +} + +func (s *Select[T]) renderOption(option Option[T], selected bool) string { + var ( + styles = s.activeStyles() + cursor = styles.SelectSelector.String() + cursorW = lipgloss.Width(cursor) + maxWidth = s.width - s.activeStyles().Base.GetHorizontalFrameSize() - cursorW + ) + + key := wrap(option.Key, maxWidth) + + if selected { + return lipgloss.JoinHorizontal( + lipgloss.Left, + cursor, + styles.SelectedOption.Render(key), + ) + } + return lipgloss.JoinHorizontal( + lipgloss.Left, + strings.Repeat(" ", cursorW), + styles.UnselectedOption.Render(key), + ) +} + +// View renders the select field. +func (s *Select[T]) View() string { + styles := s.activeStyles() + vpc, _, _ := s.optionsView() + s.viewport.SetContent(vpc) + + var parts []string + if s.title != "" { + parts = append(parts, s.titleView()) + } + if s.description != "" { + parts = append(parts, s.descriptionView()) + } + parts = append(parts, s.viewport.View()) + return styles.Base.Width(s.width).Height(s.height). + Render(strings.Join(parts, "\n")) +} + +// clearFilter clears the value of the filter. +func (s *Select[T]) clearFilter() { + s.filter.SetValue("") + s.filteredOptions = s.options + s.setFiltering(false) +} + +// setFiltering sets the filter of the select field. +func (s *Select[T]) setFiltering(filtering bool) { + if s.inline && filtering { + s.filter.SetWidth(lipgloss.Width(s.titleView()) - 1 - 1) + } + s.filtering = filtering + s.keymap.SetFilter.SetEnabled(filtering) + s.keymap.Filter.SetEnabled(!filtering) + s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") +} + +// filterFunc returns true if the option matches the filter. +func (s *Select[T]) filterFunc(option string) bool { + // XXX: remove diacritics or allow customization of filter function. + return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) +} + +// RunAccessible runs an accessible select field. +func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error { + styles := s.activeStyles() + _, _ = fmt.Fprintln(w, styles.Title. + PaddingRight(1). + Render(cmp.Or(s.title, "Select:"))) + + for i, option := range s.options { + _, _ = fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) + } + + var defaultValue *int + switch s.accessor.(type) { + case *PointerAccessor[T]: // if its of this type, it means it has a default value + s.selectOption() // make sure s.selected is set + idx := s.selected + 1 + defaultValue = &idx + } + prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) + if len(s.options) == 1 { + prompt = "There is only one option available; enter the number 1:" + } + for { + choice := promptInt(w, r, prompt, 1, len(s.options), defaultValue) + option := s.options[choice-1] + if err := s.validate(option.Value); err != nil { + _, _ = fmt.Fprintln(w, err.Error()) + _, _ = fmt.Fprintln(w) + continue + } + s.accessor.Set(option.Value) + return nil + } +} + +// WithTheme sets the theme of the select field. +func (s *Select[T]) WithTheme(theme Theme) Field { + if s.theme != nil { + return s + } + s.theme = theme + styles := s.theme.Theme(s.hasDarkBg) + + st := s.filter.Styles() + st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.Focused.TextInput.Prompt + st.Focused.Text = styles.Focused.TextInput.Text + st.Focused.Placeholder = styles.Focused.TextInput.Placeholder + s.filter.SetStyles(st) + + s.updateViewportSize() + return s +} + +// WithKeyMap sets the keymap on a select field. +func (s *Select[T]) WithKeyMap(k *KeyMap) Field { + s.keymap = k.Select + s.keymap.Left.SetEnabled(s.inline) + s.keymap.Right.SetEnabled(s.inline) + s.keymap.Up.SetEnabled(!s.inline) + s.keymap.Down.SetEnabled(!s.inline) + return s +} + +// WithWidth sets the width of the select field. +func (s *Select[T]) WithWidth(width int) Field { + s.width = width + s.updateViewportSize() + return s +} + +// WithHeight sets the height of the select field. +func (s *Select[T]) WithHeight(height int) Field { + return s.Height(height) +} + +// WithPosition sets the position of the select field. +func (s *Select[T]) WithPosition(p FieldPosition) Field { + if s.filtering { + return s + } + s.keymap.Prev.SetEnabled(!p.IsFirst()) + s.keymap.Next.SetEnabled(!p.IsLast()) + s.keymap.Submit.SetEnabled(p.IsLast()) + return s +} + +// GetValue returns the value of the field. +func (s *Select[T]) GetValue() any { + return s.accessor.Get() +} + +// GetFiltering returns the filtering state of the field. +func (s *Select[T]) GetFiltering() bool { + return s.filtering +} diff --git a/zSDKs/sdk-cli/internal/forms/field_text.go b/zSDKs/sdk-cli/internal/forms/field_text.go new file mode 100644 index 000000000..c01c6b8c9 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/field_text.go @@ -0,0 +1,386 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "cmp" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Text is a text field. +// +// A text box is responsible for getting multi-line input from the user. Use +// it to gather longer-form user input. The Text field can be filled with an +// EDITOR. +type Text struct { + accessor Accessor[string] + + title string + description string + + externalEditor bool + editorCmd string + editorArgs []string + editorExtension string + + textarea textarea.Model + + focused bool + validate func(string) error + err error + + width int + + theme Theme + hasDarkBg bool + keymap TextKeyMap +} + +// NewText creates a new text field. +// +// A text box is responsible for getting multi-line input from the user. Use +// it to gather longer-form user input. The Text field can be filled with an +// EDITOR. +func NewText() *Text { + text := textarea.New() + text.ShowLineNumbers = false + text.Prompt = "" + st := text.Styles() + st.Focused.CursorLine = lipgloss.NewStyle() + text.SetStyles(st) + + editorCmd, editorArgs := getEditor() + + t := &Text{ + accessor: &EmbeddedAccessor[string]{}, + textarea: text, + validate: func(string) error { return nil }, + externalEditor: true, + editorCmd: editorCmd, + editorArgs: editorArgs, + editorExtension: "md", + } + + return t +} + +// Value sets the value of the text field. +func (t *Text) Value(value *string) *Text { + return t.Accessor(NewPointerAccessor(value)) +} + +// Accessor sets the accessor of the text field. +func (t *Text) Accessor(accessor Accessor[string]) *Text { + t.accessor = accessor + t.textarea.SetValue(t.accessor.Get()) + return t +} + +// Title sets the text field's title. +func (t *Text) Title(title string) *Text { + t.title = title + return t +} + +// Description sets the description of the text field. +func (t *Text) Description(description string) *Text { + t.description = description + return t +} + +// Lines sets the number of lines to show of the text field. +func (t *Text) Lines(lines int) *Text { + t.textarea.SetHeight(lines) + return t +} + +// CharLimit sets the character limit of the text field. +func (t *Text) CharLimit(charlimit int) *Text { + t.textarea.CharLimit = charlimit + return t +} + +// ShowLineNumbers sets whether or not to show line numbers. +func (t *Text) ShowLineNumbers(show bool) *Text { + t.textarea.ShowLineNumbers = show + return t +} + +// Placeholder sets the placeholder of the text field. +// +// This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`. +func (t *Text) Placeholder(str string) *Text { + t.textarea.Placeholder = str + return t +} + +// Validate sets the validation function of the text field. +func (t *Text) Validate(validate func(string) error) *Text { + t.validate = validate + return t +} + +// ExternalEditor sets whether option to launch an editor is available. +func (t *Text) ExternalEditor(enabled bool) *Text { + t.externalEditor = enabled + return t +} + +const defaultEditor = "nano" + +// getEditor returns the editor command and arguments. +func getEditor() (string, []string) { + editor := strings.Fields(os.Getenv("EDITOR")) + if len(editor) > 0 { + return editor[0], editor[1:] + } + return defaultEditor, nil +} + +// Editor specifies which editor to use. +// +// The first argument provided is used as the editor command (vim, nvim, nano, etc...) +// The following (optional) arguments provided are passed as arguments to the editor command. +func (t *Text) Editor(editor ...string) *Text { + if len(editor) > 0 { + t.editorCmd = editor[0] + } + if len(editor) > 1 { + t.editorArgs = editor[1:] + } + return t +} + +// EditorExtension specifies arguments to pass into the editor. +func (t *Text) EditorExtension(extension string) *Text { + t.editorExtension = extension + return t +} + +// Error returns the error of the text field. +func (t *Text) Error() error { return t.err } + +// Skip returns whether the textarea should be skipped or should be blocking. +func (*Text) Skip() bool { return false } + +// Zoom returns whether the note should be zoomed. +func (*Text) Zoom() bool { return false } + +// Focus focuses the text field. +func (t *Text) Focus() tea.Cmd { + t.focused = true + return t.textarea.Focus() +} + +// Blur blurs the text field. +func (t *Text) Blur() tea.Cmd { + t.focused = false + t.accessor.Set(t.textarea.Value()) + t.textarea.Blur() + t.err = t.validate(t.accessor.Get()) + return nil +} + +// KeyBinds returns the help message for the text field. +func (t *Text) KeyBinds() []key.Binding { + t.keymap.Editor.SetEnabled(t.externalEditor) + return []key.Binding{t.keymap.NewLine, t.keymap.Editor, t.keymap.Prev, t.keymap.Submit, t.keymap.Next} +} + +type updateValueMsg []byte + +// Init initializes the text field. +func (t *Text) Init() tea.Cmd { + t.textarea.Blur() + return nil +} + +// Update updates the text field. +func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + t.hasDarkBg = msg.IsDark() + case updateValueMsg: + t.textarea.SetValue(string(msg)) + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + t.accessor.Set(t.textarea.Value()) + case updateFieldMsg: + return t, nil + case tea.KeyPressMsg: + t.err = nil + + switch { + case key.Matches(msg, t.keymap.Editor): + ext := strings.TrimPrefix(t.editorExtension, ".") + tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext) + cmd := exec.CommandContext( + context.TODO(), + t.editorCmd, + append(t.editorArgs, tmpFile.Name())..., + ) + _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) + cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg { + content, _ := os.ReadFile(tmpFile.Name()) + _ = os.Remove(tmpFile.Name()) + return updateValueMsg(content) + })) + case key.Matches(msg, t.keymap.Next, t.keymap.Submit): + value := t.textarea.Value() + t.err = t.validate(value) + if t.err != nil { + return t, nil + } + cmds = append(cmds, NextField) + case key.Matches(msg, t.keymap.Prev): + value := t.textarea.Value() + t.err = t.validate(value) + if t.err != nil { + return t, nil + } + cmds = append(cmds, PrevField) + } + } + + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + t.accessor.Set(t.textarea.Value()) + + return t, tea.Batch(cmds...) +} + +func (t *Text) activeStyles() *FieldStyles { + theme := t.theme + if theme == nil { + theme = ThemeFunc(themeCharm) + } + if t.focused { + return &theme.Theme(t.hasDarkBg).Focused + } + return &theme.Theme(t.hasDarkBg).Blurred +} + +// View renders the text field. +func (t *Text) View() string { + styles := t.activeStyles() + st := t.textarea.Styles() + + if t.focused { + st.Focused.Placeholder = styles.TextInput.Placeholder + st.Focused.Text = styles.TextInput.Text + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.CursorLine = styles.TextInput.Text + } else { + st.Blurred.Placeholder = styles.TextInput.Placeholder + st.Blurred.Text = styles.TextInput.Text + st.Blurred.Prompt = styles.TextInput.Prompt + st.Blurred.CursorLine = styles.TextInput.Text + } + st.Cursor.Color = styles.TextInput.Cursor.GetBackground() + t.textarea.SetStyles(st) + + maxWidth := t.width - styles.Base.GetHorizontalFrameSize() + var parts []string + if t.title != "" { + header := styles.Title.Render(wrap(t.title, maxWidth)) + if t.err != nil { + header += styles.ErrorIndicator.String() + } + parts = append(parts, header) + } + if t.description != "" { + parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) + } + parts = append(parts, t.textarea.View()) + + return styles.Base. + Render(strings.Join(parts, "\n")) +} + +// RunAccessible runs an accessible text field. +func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { + styles := t.activeStyles() + prompt := styles.Title. + PaddingRight(1). + Render(cmp.Or(t.title, "Input:")) + t.accessor.Set(promptString( + w, + r, + prompt, + t.GetValue().(string), + func(input string) error { + if err := t.validate(input); err != nil { + // Handle the error from t.validate, return it + return err + } + + if t.textarea.CharLimit > 0 && len(input) > t.textarea.CharLimit { + return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit) + } + return nil + }, + )) + return nil +} + +// WithTheme sets the theme on a text field. +func (t *Text) WithTheme(theme Theme) Field { + if t.theme != nil { + return t + } + t.theme = theme + return t +} + +// WithKeyMap sets the keymap on a text field. +func (t *Text) WithKeyMap(k *KeyMap) Field { + t.keymap = k.Text + t.textarea.KeyMap.InsertNewline.SetKeys(t.keymap.NewLine.Keys()...) + return t +} + +// WithWidth sets the width of the text field. +func (t *Text) WithWidth(width int) Field { + t.width = width + t.textarea.SetWidth(width - t.activeStyles().Base.GetHorizontalFrameSize()) + return t +} + +// WithHeight sets the height of the text field. +func (t *Text) WithHeight(height int) Field { + adjust := 0 + if t.title != "" { + adjust++ + } + if t.description != "" { + adjust++ + } + t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) + return t +} + +// WithPosition sets the position information of the text field. +func (t *Text) WithPosition(p FieldPosition) Field { + t.keymap.Prev.SetEnabled(!p.IsFirst()) + t.keymap.Next.SetEnabled(!p.IsLast()) + t.keymap.Submit.SetEnabled(p.IsLast()) + return t +} + +// GetValue returns the value of the field. +func (t *Text) GetValue() any { + return t.accessor.Get() +} diff --git a/zSDKs/sdk-cli/internal/forms/form.go b/zSDKs/sdk-cli/internal/forms/form.go new file mode 100644 index 000000000..891d59961 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/form.go @@ -0,0 +1,585 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "cmp" + "errors" + "fmt" + "io" + "os" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +const defaultWidth = 80 + +// Model is a Bubble Tea model whose Update returns the concrete field, group +// or form type rather than [tea.Model]. +type Model interface { + Init() tea.Cmd + Update(msg tea.Msg) (Model, tea.Cmd) + View() string +} + +// viewModel adapts a [Model] to [tea.Model]. +type viewModel struct { + Model + viewHook func(tea.View) tea.View +} + +// Update implements [tea.Model]. +func (w viewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := w.Model.Update(msg) + return viewModel{ + Model: m, + viewHook: w.viewHook, + }, cmd +} + +// View implements [tea.Model]. +func (w viewModel) View() tea.View { + var view tea.View + if w.viewHook != nil { + view = w.viewHook(view) + } + view.SetContent(w.Model.View()) + return view +} + +// FormState represents the current state of the form. +type FormState int + +const ( + // StateNormal is when the user is completing the form. + StateNormal FormState = iota + + // StateCompleted is when the user has completed the form. + StateCompleted + + // StateAborted is when the user has aborted the form. + StateAborted +) + +// ErrUserAborted is the error returned when a user exits the form before submitting. +var ErrUserAborted = errors.New("user aborted") + +// Form is a collection of groups that are displayed one at a time on a "page". +// +// The form can navigate between groups and is complete once all the groups are +// complete. +type Form struct { + // collection of groups + selector *selector[*Group] + + // callbacks + SubmitCmd tea.Cmd + CancelCmd tea.Cmd + + State FormState + + // whether or not to use bubble tea rendering for accessibility + // purposes, if true, the form will render with basic prompting primitives + // to be more accessible to screen readers. + accessible bool + + quitting bool + aborted bool + + // options + width int + height int + theme Theme + hasDarkBg bool + keymap *KeyMap + teaOptions []tea.ProgramOption + + // accessible mode IO + output io.Writer + input io.Reader +} + +// NewForm returns a form with the given groups and default themes and +// keybindings. +// +// Use With* methods to customize the form with options, such as setting +// different themes and keybindings. +func NewForm(groups ...*Group) *Form { + selector := newSelector(groups) + + f := &Form{ + selector: selector, + keymap: NewDefaultKeyMap(), + teaOptions: []tea.ProgramOption{ + tea.WithOutput(os.Stderr), + }, + } + + // NB: If dynamic forms come into play this will need to be applied when + // groups and fields are added. + f.WithKeyMap(f.keymap) + f.WithWidth(f.width) + f.WithHeight(f.height) + f.UpdateFieldPositions() + + if os.Getenv("TERM") == "dumb" { + f.WithWidth(defaultWidth) + f.WithAccessible(true) + } + + return f +} + +// Field is a primitive of a form. +// +// A field represents a single input control on a form such as a text input, +// confirm button, select option, etc... +// +// Each field implements the Bubble Tea Model interface. +type Field interface { + // Bubble Tea Model + Model + + // Bubble Tea Events + Blur() tea.Cmd + Focus() tea.Cmd + + // Errors and Validation + Error() error + + // RunAccessible runs the field in accessible mode with the given IO. + RunAccessible(w io.Writer, r io.Reader) error + + // Skip returns whether this input should be skipped or not. + Skip() bool + + // Zoom returns whether this input should be zoomed or not. + // Zoom allows the field to take focus of the group / form height. + Zoom() bool + + // KeyBinds returns help keybindings. + KeyBinds() []key.Binding + + // WithTheme sets the theme on a field. + WithTheme(Theme) Field + + // WithKeyMap sets the keymap on a field. + WithKeyMap(*KeyMap) Field + + // WithWidth sets the width of a field. + WithWidth(int) Field + + // WithHeight sets the height of a field. + WithHeight(int) Field + + // WithPosition tells the field the index of the group and position it is in. + WithPosition(FieldPosition) Field + + // GetValue returns the field's value. + GetValue() any +} + +// FieldPosition is positional information about the given field and form. +type FieldPosition struct { + Group int + Field int + FirstField int + LastField int + GroupCount int + FirstGroup int + LastGroup int +} + +// IsFirst returns whether a field is the form's first field. +func (p FieldPosition) IsFirst() bool { + return p.Field == p.FirstField && p.Group == p.FirstGroup +} + +// IsLast returns whether a field is the form's last field. +func (p FieldPosition) IsLast() bool { + return p.Field == p.LastField && p.Group == p.LastGroup +} + +// nextGroupMsg is a message to move to the next group. +type nextGroupMsg struct{} + +// prevGroupMsg is a message to move to the previous group. +type prevGroupMsg struct{} + +// nextGroup is the command to move to the next group. +func nextGroup() tea.Msg { + return nextGroupMsg{} +} + +// prevGroup is the command to move to the previous group. +func prevGroup() tea.Msg { + return prevGroupMsg{} +} + +// WithAccessible sets the form to run in accessible mode to avoid redrawing the +// views which makes it easier for screen readers to read and describe the form. +// +// This avoids using the Bubble Tea renderer and instead simply uses basic +// terminal prompting to gather input which degrades the user experience but +// provides accessibility. +func (f *Form) WithAccessible(accessible bool) *Form { + f.accessible = accessible + return f +} + +// WithShowHelp sets whether or not the form should show help. +// +// This allows the form groups and field to show what keybindings are available +// to the user. +func (f *Form) WithShowHelp(v bool) *Form { + f.selector.Range(func(_ int, group *Group) bool { + group.WithShowHelp(v) + return true + }) + return f +} + +// WithTheme sets the theme on a form. +// +// This allows all groups and fields to be themed consistently, however themes +// can be applied to each group and field individually for more granular +// control. +func (f *Form) WithTheme(theme Theme) *Form { + if theme == nil { + return f + } + f.theme = theme + f.selector.Range(func(_ int, group *Group) bool { + group.WithTheme(theme) + return true + }) + return f +} + +// WithKeyMap sets the keymap on a form. +// +// This allows customization of the form key bindings. +func (f *Form) WithKeyMap(keymap *KeyMap) *Form { + if keymap == nil { + return f + } + f.keymap = keymap + f.selector.Range(func(_ int, group *Group) bool { + group.WithKeyMap(keymap) + return true + }) + f.UpdateFieldPositions() + return f +} + +// WithWidth sets the width of a form. +// +// This allows all groups and fields to be sized consistently, however width +// can be applied to each group and field individually for more granular +// control. +func (f *Form) WithWidth(width int) *Form { + if width <= 0 { + return f + } + f.width = width + f.selector.Range(func(_ int, group *Group) bool { + group.WithWidth(width) + return true + }) + return f +} + +// WithHeight sets the height of a form. +func (f *Form) WithHeight(height int) *Form { + if height <= 0 { + return f + } + f.height = height + f.selector.Range(func(_ int, group *Group) bool { + group.WithHeight(height) + return true + }) + return f +} + +// WithOutput sets the io.Writer to output the form. +// Default is STDOUT when [Form] is accessible (set with [Form.WithAccessible], STDERR otherwise. +func (f *Form) WithOutput(w io.Writer) *Form { + f.output = w + f.teaOptions = append(f.teaOptions, tea.WithOutput(w)) + return f +} + +// WithInput sets the io.Reader to the input form. +// Default is STDIN. +func (f *Form) WithInput(r io.Reader) *Form { + f.input = r + f.teaOptions = append(f.teaOptions, tea.WithInput(r)) + return f +} + +// UpdateFieldPositions sets the position on all the fields. +func (f *Form) UpdateFieldPositions() *Form { + firstGroup := 0 + lastGroup := f.selector.Total() - 1 + + // determine the first non-hidden group. + f.selector.Range(func(_ int, g *Group) bool { + if !f.isGroupHidden(g) { + return false + } + firstGroup++ + return true + }) + + // determine the last non-hidden group. + f.selector.ReverseRange(func(_ int, g *Group) bool { + if !f.isGroupHidden(g) { + return false + } + lastGroup-- + return true + }) + + f.selector.Range(func(g int, group *Group) bool { + // determine the first non-skippable field. + var firstField int + group.selector.Range(func(_ int, field Field) bool { + if !field.Skip() || group.selector.Total() == 1 { + return false + } + firstField++ + return true + }) + + // determine the last non-skippable field. + var lastField int + group.selector.ReverseRange(func(i int, field Field) bool { + lastField = i + if !field.Skip() || group.selector.Total() == 1 { + return false + } + return true + }) + + group.selector.Range(func(i int, field Field) bool { + field.WithPosition(FieldPosition{ + Group: g, + Field: i, + FirstField: firstField, + LastField: lastField, + FirstGroup: firstGroup, + LastGroup: lastGroup, + }) + return true + }) + + return true + }) + return f +} + +// Init initializes the form. +func (f *Form) Init() tea.Cmd { + var cmds []tea.Cmd + f.selector.Range(func(i int, group *Group) bool { + if i == 0 { + group.active = true + } + cmds = append(cmds, group.Init()) + return true + }) + + if f.isGroupHidden(f.selector.Selected()) { + cmds = append(cmds, nextGroup) + } + + cmds = append(cmds, tea.RequestWindowSize) + return tea.Sequence(cmds...) +} + +// Update updates the form. +func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) { + // If the form is aborted or completed there's no need to update it. + if f.State != StateNormal { + return f, nil + } + + group := f.selector.Selected() + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + f.hasDarkBg = msg.IsDark() + case tea.WindowSizeMsg: + if f.width == 0 { + f.selector.Range(func(_ int, group *Group) bool { + group.WithWidth(msg.Width) + return true + }) + } + if f.height == 0 { + // calculate the needed height, which is the height of the + // heightest group, accounting for the width, wraps, etc. + neededHeight := 0 + f.selector.Range(func(_ int, group *Group) bool { + neededHeight = max(neededHeight, group.rawHeight()) + return true + }) + + f.selector.Range(func(_ int, group *Group) bool { + group.WithHeight(min(neededHeight, msg.Height)) + return true + }) + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, f.keymap.Quit): + f.aborted = true + f.quitting = true + f.State = StateAborted + return f, f.CancelCmd + } + + case nextGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + + submit := func() (Model, tea.Cmd) { + f.quitting = true + f.State = StateCompleted + return f, f.SubmitCmd + } + + if f.selector.OnLast() { + return submit() + } + + for i := f.selector.Index() + 1; i < f.selector.Total(); i++ { + if !f.isGroupHidden(f.selector.Get(i)) { + f.selector.SetIndex(i) + break + } + // all subsequent groups are hidden, so we must act as + // if we were in the last one. + if i == f.selector.Total()-1 { + return submit() + } + } + f.selector.Selected().active = true + return f, f.selector.Selected().Init() + + case prevGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + + for i := f.selector.Index() - 1; i >= 0; i-- { + if !f.isGroupHidden(f.selector.Get(i)) { + f.selector.SetIndex(i) + break + } + } + + f.selector.Selected().active = true + return f, f.selector.Selected().Init() + } + + m, cmd := group.Update(msg) + f.selector.Set(f.selector.Index(), m.(*Group)) + + // A user input a key, this could hide or show other groups, + // let's update all of their positions. + switch msg.(type) { + case tea.KeyPressMsg: + f.UpdateFieldPositions() + } + + return f, cmd +} + +func (f *Form) isGroupHidden(group *Group) bool { + hide := group.hide + if hide == nil { + return false + } + return hide() +} + +func (f *Form) getTheme() *Styles { + if f.theme != nil { + return f.theme.Theme(f.hasDarkBg) + } + return themeCharm(f.hasDarkBg) +} + +func (f *Form) styles() FormStyles { + return f.getTheme().Form +} + +// View renders the form. +func (f *Form) View() string { + if f.quitting { + return "" + } + + return f.styles().Base.Render(f.selector.Selected().View()) +} + +// Run runs the form. +func (f *Form) Run() error { + f.SubmitCmd = tea.Quit + f.CancelCmd = tea.Interrupt + + if f.selector.Total() == 0 { + return nil + } + + if f.accessible { + return f.runAccessible( + cmp.Or[io.Writer](f.output, os.Stdout), + cmp.Or[io.Reader](f.input, os.Stdin), + ) + } + + return f.run() +} + +// run runs the form in normal mode. +func (f *Form) run() error { + m, err := tea.NewProgram( + viewModel{ + Model: f, + viewHook: func(v tea.View) tea.View { + v.ReportFocus = true + return v + }, + }, + f.teaOptions..., + ).Run() + if m.(viewModel).Model.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + if err != nil { + return fmt.Errorf("form: %w", err) + } + return nil +} + +// runAccessible runs the form in accessible mode. +func (f *Form) runAccessible(w io.Writer, r io.Reader) error { + f.selector.Range(func(_ int, group *Group) bool { + group.selector.Range(func(_ int, field Field) bool { + field.Init() + field.Focus() + _ = field.RunAccessible(w, r) + _, _ = fmt.Fprintln(w) + return true + }) + return true + }) + + return nil +} diff --git a/zSDKs/sdk-cli/internal/forms/group.go b/zSDKs/sdk-cli/internal/forms/group.go new file mode 100644 index 000000000..c08940dc0 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/group.go @@ -0,0 +1,410 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Group is a collection of fields that are displayed together with a page of +// the form. While a group is displayed the form completer can switch between +// fields in the group. +// +// If any of the fields in a group have errors, the form will not be able to +// progress to the next group. +type Group struct { + // collection of fields + selector *selector[Field] + + // information + title string + description string + + // navigation + viewport viewport.Model + + // help + showHelp bool + help help.Model + + // errors + showErrors bool + + // group options + width int + height int + theme Theme + hasDarkBg bool + keymap *KeyMap + hide func() bool + active bool +} + +// NewGroup returns a new group with the given fields. +func NewGroup(fields ...Field) *Group { + selector := newSelector(fields) + group := &Group{ + selector: selector, + help: help.New(), + showHelp: true, + showErrors: true, + active: false, + } + + group.width = 80 + height := group.rawHeight() + v := viewport.New( + viewport.WithWidth(group.width), + viewport.WithHeight(height), + ) + group.viewport = v + group.height = height + + return group +} + +// Title sets the group's title. +func (g *Group) Title(title string) *Group { + g.title = title + return g +} + +// Description sets the group's description. +func (g *Group) Description(description string) *Group { + g.description = description + return g +} + +// WithShowHelp sets whether or not the group's help should be shown. +func (g *Group) WithShowHelp(show bool) *Group { + g.showHelp = show + return g +} + +// WithShowErrors sets whether or not the group's errors should be shown. +func (g *Group) WithShowErrors(show bool) *Group { + g.showErrors = show + return g +} + +// WithTheme sets the theme on a group. +func (g *Group) WithTheme(t Theme) *Group { + g.theme = t + g.help.Styles = t.Theme(g.hasDarkBg).Help + g.selector.Range(func(_ int, field Field) bool { + field.WithTheme(t) + return true + }) + if g.height <= 0 { + g.WithHeight(g.rawHeight()) + } + return g +} + +// WithKeyMap sets the keymap on a group. +func (g *Group) WithKeyMap(k *KeyMap) *Group { + g.keymap = k + g.selector.Range(func(_ int, field Field) bool { + field.WithKeyMap(k) + return true + }) + return g +} + +// WithWidth sets the width on a group. +func (g *Group) WithWidth(width int) *Group { + g.width = width + g.viewport.SetWidth(width) + g.help.SetWidth(width) + g.selector.Range(func(_ int, field Field) bool { + field.WithWidth(width) + return true + }) + return g +} + +// WithHeight sets the height on a group. +func (g *Group) WithHeight(height int) *Group { + g.height = height + h := height - g.titleFooterHeight() + g.viewport.SetHeight(h) + g.selector.Range(func(_ int, field Field) bool { + // A field height must not exceed the form height. + if h < lipgloss.Height(field.View()) { + field.WithHeight(h) + } + return true + }) + return g +} + +// WithHide sets whether this group should be skipped. +func (g *Group) WithHide(hide bool) *Group { + g.WithHideFunc(func() bool { return hide }) + return g +} + +// WithHideFunc sets the function that checks if this group should be skipped. +func (g *Group) WithHideFunc(hideFunc func() bool) *Group { + g.hide = hideFunc + return g +} + +// Errors returns the groups' fields' errors. +func (g *Group) Errors() []error { + var errs []error + g.selector.Range(func(_ int, field Field) bool { + if err := field.Error(); err != nil { + errs = append(errs, err) + } + return true + }) + return errs +} + +// updateFieldMsg is a message to update the fields of a group that is currently +// displayed. +// +// This is used to update all TitleFunc, DescriptionFunc, and ...Func update +// methods to make all fields dynamically update based on user input. +type updateFieldMsg struct{} + +// nextFieldMsg is a message to move to the next field, +// +// each field controls when to send this message such that it is able to use +// different key bindings or events to trigger group progression. +type nextFieldMsg struct{} + +// prevFieldMsg is a message to move to the previous field. +// +// each field controls when to send this message such that it is able to use +// different key bindings or events to trigger group progression. +type prevFieldMsg struct{} + +// NextField is the command to move to the next field. +func NextField() tea.Msg { + return nextFieldMsg{} +} + +// PrevField is the command to move to the previous field. +func PrevField() tea.Msg { + return prevFieldMsg{} +} + +// Init initializes the group. +func (g *Group) Init() tea.Cmd { + var cmds []tea.Cmd + + cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) + + if g.selector.Selected().Skip() { + if g.selector.OnLast() { + cmds = append(cmds, g.prevField()...) + } else if g.selector.OnFirst() { + cmds = append(cmds, g.nextField()...) + } + return tea.Batch(cmds...) + } + + if g.active { + cmd := g.selector.Selected().Focus() + cmds = append(cmds, cmd) + } + g.buildView() + return tea.Batch(cmds...) +} + +// nextField moves to the next field. +func (g *Group) nextField() []tea.Cmd { + blurCmd := g.selector.Selected().Blur() + if g.selector.OnLast() { + return []tea.Cmd{blurCmd, nextGroup} + } + g.selector.Next() + for g.selector.Selected().Skip() { + if g.selector.OnLast() { + return []tea.Cmd{blurCmd, nextGroup} + } + g.selector.Next() + } + focusCmd := g.selector.Selected().Focus() + return []tea.Cmd{blurCmd, focusCmd} +} + +// prevField moves to the previous field. +func (g *Group) prevField() []tea.Cmd { + blurCmd := g.selector.Selected().Blur() + if g.selector.OnFirst() { + return []tea.Cmd{blurCmd, prevGroup} + } + g.selector.Prev() + for g.selector.Selected().Skip() { + if g.selector.OnFirst() { + return []tea.Cmd{blurCmd, prevGroup} + } + g.selector.Prev() + } + focusCmd := g.selector.Selected().Focus() + return []tea.Cmd{blurCmd, focusCmd} +} + +// Update updates the group. +func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) { + var cmds []tea.Cmd + + // Update all the fields in the group. + g.selector.Range(func(i int, field Field) bool { + switch msg := msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + break + default: + m, cmd := field.Update(msg) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + } + if g.selector.Index() == i { + m, cmd := field.Update(msg) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + } + m, cmd := field.Update(updateFieldMsg{}) + g.selector.Set(i, m.(Field)) + cmds = append(cmds, cmd) + return true + }) + + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + g.hasDarkBg = msg.IsDark() + case nextFieldMsg: + cmds = append(cmds, g.nextField()...) + case prevFieldMsg: + cmds = append(cmds, g.prevField()...) + } + + g.buildView() + + return g, tea.Batch(cmds...) +} + +func (g *Group) getTheme() *Styles { + if theme := g.theme; theme != nil { + return theme.Theme(g.hasDarkBg) + } + return ThemeFunc(themeCharm).Theme(g.hasDarkBg) +} + +func (g *Group) styles() GroupStyles { return g.getTheme().Group } + +func (g *Group) getContent() (int, string) { + var fields strings.Builder + offset := 0 + + gap := g.getTheme().FieldSeparator.Render() + + // if the focused field is requesting it be zoomed, only show that field. + if g.selector.Selected().Zoom() { + g.selector.Selected().WithHeight(g.height) + fields.WriteString(g.selector.Selected().View()) + } else { + g.selector.Range(func(i int, field Field) bool { + fields.WriteString(field.View()) + if i == g.selector.Index() { + offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) + } + if i < g.selector.Total()-1 { + fields.WriteString(gap) + } + return true + }) + } + + return offset, fields.String() +} + +func (g *Group) buildView() { + offset, content := g.getContent() + g.viewport.SetContent(content) + g.viewport.SetYOffset(offset) +} + +// Header renders the group's header only (no content). +func (g *Group) Header() string { + styles := g.styles() + var parts []string + if g.title != "" { + parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) + } + if g.description != "" { + parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) + } + return strings.Join(parts, "\n") +} + +// titleFooterHeight returns the height of the footer + header. +func (g *Group) titleFooterHeight() int { + h := 0 + if s := g.Header(); s != "" { + h += lipgloss.Height(s) + } + if s := g.Footer(); s != "" { + h += lipgloss.Height(s) + } + return h +} + +// rawHeight returns the full height of the group, without using a viewport. +func (g *Group) rawHeight() int { + return lipgloss.Height(g.Content()) + g.titleFooterHeight() +} + +// View renders the group. +func (g *Group) View() string { + var parts []string + if s := g.Header(); s != "" { + parts = append(parts, s) + } + parts = append(parts, g.viewport.View()) + if s := g.Footer(); s != "" { + // append an empty line, and the footer (usually the help). + parts = append(parts, "", s) + } + if len(parts) > 0 { + // Trim suffix spaces from the last part as it can accidentally + // scroll the view up on some terminals (like Apple's Terminal.app) + // when we right to the bottom rightmost corner cell. + lastIdx := len(parts) - 1 + parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") + } + return strings.Join(parts, "\n") +} + +// Content renders the group's content only (no footer). +func (g *Group) Content() string { + _, content := g.getContent() + return content +} + +// Footer renders the group's footer only (no content). +func (g *Group) Footer() string { + var parts []string + errors := g.Errors() + if g.showHelp && len(errors) <= 0 { + parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) + } + if g.showErrors { + for _, err := range errors { + parts = append(parts, wrap( + g.getTheme().Focused.ErrorMessage.Render(err.Error()), + g.width, + )) + } + } + return g.styles().Base. + Render(strings.Join(parts, "\n")) +} diff --git a/zSDKs/sdk-cli/internal/forms/keymap.go b/zSDKs/sdk-cli/internal/forms/keymap.go new file mode 100644 index 000000000..382dfd28c --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/keymap.go @@ -0,0 +1,104 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import "charm.land/bubbles/v2/key" + +// KeyMap is the keybindings to navigate the form. +type KeyMap struct { + Quit key.Binding + + Confirm ConfirmKeyMap + Input InputKeyMap + Select SelectKeyMap + Text TextKeyMap +} + +// InputKeyMap is the keybindings for input fields. +type InputKeyMap struct { + AcceptSuggestion key.Binding + Next key.Binding + Prev key.Binding + Submit key.Binding +} + +// TextKeyMap is the keybindings for text fields. +type TextKeyMap struct { + Next key.Binding + Prev key.Binding + NewLine key.Binding + Editor key.Binding + Submit key.Binding +} + +// SelectKeyMap is the keybindings for select fields. +type SelectKeyMap struct { + Next key.Binding + Prev key.Binding + Up key.Binding + Down key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding + Left key.Binding + Right key.Binding + Filter key.Binding + SetFilter key.Binding + ClearFilter key.Binding + Submit key.Binding +} + +// ConfirmKeyMap is the keybindings for confirm fields. +type ConfirmKeyMap struct { + Next key.Binding + Prev key.Binding + Toggle key.Binding + Submit key.Binding + Accept key.Binding + Reject key.Binding +} + +// NewDefaultKeyMap returns a new default keymap. +func NewDefaultKeyMap() *KeyMap { + return &KeyMap{ + Quit: key.NewBinding(key.WithKeys("ctrl+c")), + Input: InputKeyMap{ + AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + }, + Text: TextKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), + Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), + }, + Select: SelectKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), + Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), + Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), + ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), + HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), + HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), + GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), + GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), + }, + Confirm: ConfirmKeyMap{ + Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), + Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), + Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), + Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), + Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), + }, + } +} diff --git a/zSDKs/sdk-cli/internal/forms/option.go b/zSDKs/sdk-cli/internal/forms/option.go new file mode 100644 index 000000000..b794fae53 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/option.go @@ -0,0 +1,19 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +// Option is an option for select fields. +type Option[T comparable] struct { + Key string + Value T +} + +// NewOption returns a new select option. +func NewOption[T comparable](key string, value T) Option[T] { + return Option[T]{Key: key, Value: value} +} + +// String returns the key of the option. +func (o Option[T]) String() string { + return o.Key +} diff --git a/zSDKs/sdk-cli/internal/forms/selector.go b/zSDKs/sdk-cli/internal/forms/selector.go new file mode 100644 index 000000000..e94d06b8e --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/selector.go @@ -0,0 +1,98 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +// selector is a helper type for selecting items. +type selector[T any] struct { + items []T + index int +} + +// newSelector creates a new item selector. +func newSelector[T any](items []T) *selector[T] { + return &selector[T]{ + items: items, + } +} + +// Append adds an item to the selector. +func (s *selector[T]) Append(item T) { + s.items = append(s.items, item) +} + +// Next moves the selector to the next item. +func (s *selector[T]) Next() { + if s.index < len(s.items)-1 { + s.index++ + } +} + +// Prev moves the selector to the previous item. +func (s *selector[T]) Prev() { + if s.index > 0 { + s.index-- + } +} + +// OnFirst returns true if the selector is on the first item. +func (s *selector[T]) OnFirst() bool { + return s.index == 0 +} + +// OnLast returns true if the selector is on the last item. +func (s *selector[T]) OnLast() bool { + return s.index == len(s.items)-1 +} + +// Selected returns the index of the current selected item. +func (s *selector[T]) Selected() T { + return s.items[s.index] +} + +// Index returns the index of the current selected item. +func (s *selector[T]) Index() int { + return s.index +} + +// Total returns the total number of items. +func (s *selector[T]) Total() int { + return len(s.items) +} + +// SetIndex sets the selected item. +func (s *selector[T]) SetIndex(i int) { + if i < 0 || i >= len(s.items) { + return + } + s.index = i +} + +// Get returns the item at the given index. +func (s *selector[T]) Get(i int) T { + return s.items[i] +} + +// Set sets the item at the given index. +func (s *selector[T]) Set(i int, item T) { + s.items[i] = item +} + +// Range iterates over the items. +// The callback function should return true to continue the iteration. +func (s *selector[T]) Range(f func(i int, item T) bool) { + for i, item := range s.items { + if !f(i, item) { + break + } + } +} + +// ReverseRange iterates over the items in reverse. +// The callback function should return true to continue the iteration. +func (s *selector[T]) ReverseRange(f func(i int, item T) bool) { + for i := len(s.items) - 1; i >= 0; i-- { + if !f(i, s.items[i]) { + break + } + } +} diff --git a/zSDKs/sdk-cli/internal/forms/theme.go b/zSDKs/sdk-cli/internal/forms/theme.go new file mode 100644 index 000000000..28eeb5ec9 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/theme.go @@ -0,0 +1,157 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "charm.land/bubbles/v2/help" + "charm.land/lipgloss/v2" +) + +// Theme represents a theme for a form. +type Theme interface { + Theme(isDark bool) *Styles +} + +// ThemeFunc is a function that returns a new theme. +type ThemeFunc func(isDark bool) *Styles + +// Theme implements the Theme interface. +func (f ThemeFunc) Theme(isDark bool) *Styles { + return f(isDark) +} + +// Styles is a collection of styles for components of the form. +// Themes can be applied to a form using the WithTheme option. +type Styles struct { + Form FormStyles + Group GroupStyles + FieldSeparator lipgloss.Style + Blurred FieldStyles + Focused FieldStyles + Help help.Styles +} + +// FormStyles are the styles for a form. +type FormStyles struct { + Base lipgloss.Style +} + +// GroupStyles are the styles for a group. +type GroupStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style +} + +// FieldStyles are the styles for input fields. +type FieldStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + ErrorIndicator lipgloss.Style + ErrorMessage lipgloss.Style + + // Select styles. + SelectSelector lipgloss.Style // Selection indicator + NextIndicator lipgloss.Style + PrevIndicator lipgloss.Style + SelectedOption lipgloss.Style + UnselectedOption lipgloss.Style + + // Textinput and teatarea styles. + TextInput TextInputStyles + + // Confirm styles. + FocusedButton lipgloss.Style + BlurredButton lipgloss.Style +} + +// TextInputStyles are the styles for text inputs. +type TextInputStyles struct { + Cursor lipgloss.Style + CursorText lipgloss.Style + Placeholder lipgloss.Style + Prompt lipgloss.Style + Text lipgloss.Style +} + +const ( + buttonPaddingHorizontal = 2 + buttonPaddingVertical = 0 +) + +// ThemeBase returns a new base theme with general styles to be inherited by +// other themes. +func ThemeBase(bool) *Styles { + var t Styles + + t.Form.Base = lipgloss.NewStyle() + t.Group.Base = lipgloss.NewStyle() + t.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") + + button := lipgloss.NewStyle(). + Padding(buttonPaddingVertical, buttonPaddingHorizontal). + MarginRight(1) + + // Focused styles. + t.Focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) + t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") + t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") + t.Focused.SelectSelector = lipgloss.NewStyle().SetString("> ") + t.Focused.NextIndicator = lipgloss.NewStyle().MarginLeft(1).SetString("→") + t.Focused.PrevIndicator = lipgloss.NewStyle().MarginRight(1).SetString("←") + t.Focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) + t.Focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) + t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + t.Help = help.New().Styles + + // Blurred styles. + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return &t +} + +// themeCharm returns a new theme based on the Charm color scheme. +func themeCharm(isDark bool) *Styles { + t := ThemeBase(isDark) + lightDark := lipgloss.LightDark(isDark) + + var ( + normalFg = lightDark(lipgloss.Color("252"), lipgloss.Color("235")) + indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) + cream = lightDark(lipgloss.Color("#FFFDF5"), lipgloss.Color("#FFFDF5")) + fuchsia = lipgloss.Color("#F780E2") + green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) + red = lightDark(lipgloss.Color("#FF4672"), lipgloss.Color("#ED567A")) + ) + + t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("238")) + t.Focused.Title = t.Focused.Title.Foreground(indigo).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(fuchsia) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(fuchsia) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(fuchsia) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(normalFg) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(fuchsia) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lightDark(lipgloss.Color("237"), lipgloss.Color("252"))) + + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(green) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("238"))) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(fuchsia) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} diff --git a/zSDKs/sdk-cli/internal/forms/wrap.go b/zSDKs/sdk-cli/internal/forms/wrap.go new file mode 100644 index 000000000..cba9b9250 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/wrap.go @@ -0,0 +1,9 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import "charm.land/lipgloss/v2" + +func wrap(s string, limit int) string { + return lipgloss.Wrap(s, limit, ",.-; ") +} diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index 45d471482..d700b43ac 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -14,7 +14,6 @@ import ( "strings" "time" - "charm.land/huh/v2" "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -22,6 +21,7 @@ import ( "openapi/internal/config" "openapi/internal/flagutil" + "openapi/internal/forms" "openapi/internal/output" "openapi/internal/usage" ) @@ -205,7 +205,7 @@ func interceptCommand(cmd *cobra.Command) { if missing { prompter := injectedPrompter(runCmd) if prompter == nil { - prompter = huhPrompter{} + prompter = formPrompter{} } answers, err := prompter.Prompt(runCmd, fields) if err != nil { @@ -611,7 +611,7 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing huh forms, with a sensible default. +// formWidth returns the terminal width for sizing forms, with a sensible default. func formWidth() int { width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil || width <= 0 { @@ -621,18 +621,17 @@ func formWidth() int { } // formTheme builds the interactive prompt theme. -func formTheme() huh.Theme { - return huh.ThemeFunc(formStyles) +func formTheme() forms.Theme { + return forms.ThemeFunc(formStyles) } -func formStyles(isDark bool) *huh.Styles { - t := *huh.ThemeBase(isDark) +func formStyles(isDark bool) *forms.Styles { + t := *forms.ThemeBase(isDark) accent := lipgloss.Color("#38BDF8") dimmed := lipgloss.Color("#64748B") subtle := lipgloss.Color("#475569") errColor := lipgloss.Color("#F87171") - greenColor := lipgloss.Color("#4ADE80") t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) @@ -641,11 +640,8 @@ func formStyles(isDark bool) *huh.Styles { t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(greenColor).SetString("✓ ").Bold(true) - t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.Next = t.Focused.FocusedButton t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) @@ -657,8 +653,6 @@ func formStyles(isDark bool) *huh.Styles { t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - t.Blurred.SelectedPrefix = lipgloss.NewStyle().Foreground(dimmed).SetString("✓ ") - t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString(" ") return &t } @@ -689,9 +683,9 @@ func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { fmt.Fprintln(os.Stderr) } -type huhPrompter struct{} +type formPrompter struct{} -func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { +func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAnswer, error) { answers := make([]PromptAnswer, len(fields)) required := make([]int, 0, len(fields)) optionalArgs := make([]int, 0, len(fields)) @@ -741,7 +735,7 @@ func (huhPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAns for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := huh.NewForm(huh.NewGroup(huh.NewConfirm(). + form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). Description(strings.Join(names, ", ")).Value(&fillOptional))). WithWidth(width).WithTheme(theme).WithShowHelp(false) @@ -767,21 +761,21 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { return runRepeatableFlagPrompt(field, theme, width) } var answer PromptAnswer - var formField huh.Field + var formField forms.Field switch field.Kind { case "bool": if field.Required { value := false - formField = huh.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) + formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) answer.Set = true answer.Values = []string{strconv.FormatBool(value)} - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -789,10 +783,10 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return answer, nil } value := "" - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( - huh.NewOption("Yes", "true"), huh.NewOption("No", "false"), huh.NewOption("Skip (use default)", ""), + formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( + forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), ).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -800,15 +794,15 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer case "enum": value := "" - options := make([]huh.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option[string], 0, len(field.Options)+1) if !field.Required { - options = append(options, huh.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption[string]("Skip (use default)", "")) } for _, option := range field.Options { - options = append(options, huh.NewOption(option, option)) + options = append(options, forms.NewOption(option, option)) } - formField = huh.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -828,12 +822,12 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer } switch field.Kind { case "json": - formField = huh.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) + formField = forms.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) if validate != nil { - formField = formField.(*huh.Text).Validate(validate) + formField = formField.(*forms.Text).Validate(validate) } case "int64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -846,7 +840,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) case "float64": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -859,7 +853,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) case "duration": - formField = huh.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { + formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { if strings.TrimSpace(value) == "" { if validate != nil { return validate(value) @@ -872,13 +866,13 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return nil }) default: - input := huh.NewInput().Title(field.Name).Description(description).Value(&value) + input := forms.NewInput().Title(field.Name).Description(description).Value(&value) if validate != nil { input = input.Validate(validate) } formField = input } - form := huh.NewForm(huh.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -888,7 +882,7 @@ func runPromptField(field PromptField, theme huh.Theme, width int) (PromptAnswer return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (PromptAnswer, error) { +func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -898,7 +892,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := huh.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput().Title(title).Description(description).Value(&value) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -907,7 +901,7 @@ func runRepeatableFlagPrompt(field PromptField, theme huh.Theme, width int) (Pro return nil }) } - form := huh.NewForm(huh.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) if err := form.Run(); err != nil { return PromptAnswer{}, err } From 46b17dc12cfd46a326c3b293545b60500ef6534e Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 23:09:00 +0100 Subject: [PATCH 2/9] chore(cli): reduce the forms package to the paths generated CLIs use internal/forms is now a single generated file, about half its previous size. Every call site rendered with the same theme, the terminal width, and help hidden, so those become fixed behaviour of the package. The theme, width and help options, and the copies of the theme and width helpers in auth.go, configure.go and interactive.go, are removed. Also removed because no generated code reaches them: select filtering, the external-editor shortcut on text fields, inline layouts, custom confirm labels, character limits, hidden groups, field keys, value accessors, and the dark-background plumbing (the theme ignores it). Select stores strings only, and Input.EchoMode(EchoModePassword) becomes Input.Password(). Two key handling changes follow: "/" no longer opens a filter in selects, and ctrl+e in an input moves to the end of the line instead of being swallowed by an unused suggestion binding. --- templates/perms.go | 1531 ++++++++--------- templates/templates/cli/README.md | 2 +- templates/templates/cli/auth.go.stmpl | 58 - .../internal/forms/accessible.go.stmpl | 182 -- .../internal/forms/accessor.go.stmpl | 46 - .../cli/auxiliary/internal/forms/doc.go.stmpl | 31 - .../internal/forms/field_confirm.go.stmpl | 308 ---- .../internal/forms/field_input.go.stmpl | 345 ---- .../internal/forms/field_select.go.stmpl | 680 -------- .../internal/forms/field_text.go.stmpl | 386 ----- .../auxiliary/internal/forms/form.go.stmpl | 585 ------- .../auxiliary/internal/forms/forms.go.stmpl | 1278 ++++++++++++++ .../auxiliary/internal/forms/group.go.stmpl | 410 ----- .../auxiliary/internal/forms/keymap.go.stmpl | 104 -- .../auxiliary/internal/forms/option.go.stmpl | 19 - .../internal/forms/selector.go.stmpl | 98 -- .../auxiliary/internal/forms/theme.go.stmpl | 157 -- .../auxiliary/internal/forms/wrap.go.stmpl | 9 - .../internal/interactive/interactive.go.stmpl | 82 +- templates/templates/cli/configure.go.stmpl | 66 +- templates/templates/cli/includes/security.ts | 28 +- zSDKs/sdk-cli/.speakeasy/gen.lock | 36 +- zSDKs/sdk-cli/internal/cli/auth.go | 124 +- zSDKs/sdk-cli/internal/cli/configure.go | 84 +- zSDKs/sdk-cli/internal/forms/accessible.go | 182 -- zSDKs/sdk-cli/internal/forms/accessor.go | 46 - zSDKs/sdk-cli/internal/forms/doc.go | 31 - zSDKs/sdk-cli/internal/forms/field_confirm.go | 308 ---- zSDKs/sdk-cli/internal/forms/field_input.go | 345 ---- zSDKs/sdk-cli/internal/forms/field_select.go | 680 -------- zSDKs/sdk-cli/internal/forms/field_text.go | 386 ----- zSDKs/sdk-cli/internal/forms/form.go | 585 ------- zSDKs/sdk-cli/internal/forms/forms.go | 1278 ++++++++++++++ zSDKs/sdk-cli/internal/forms/group.go | 410 ----- zSDKs/sdk-cli/internal/forms/keymap.go | 104 -- zSDKs/sdk-cli/internal/forms/option.go | 19 - zSDKs/sdk-cli/internal/forms/selector.go | 98 -- zSDKs/sdk-cli/internal/forms/theme.go | 157 -- zSDKs/sdk-cli/internal/forms/wrap.go | 9 - .../internal/interactive/interactive.go | 82 +- 40 files changed, 3398 insertions(+), 7971 deletions(-) delete mode 100644 templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/form.go.stmpl create mode 100644 templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/group.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/option.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl delete mode 100644 templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl delete mode 100644 zSDKs/sdk-cli/internal/forms/accessible.go delete mode 100644 zSDKs/sdk-cli/internal/forms/accessor.go delete mode 100644 zSDKs/sdk-cli/internal/forms/doc.go delete mode 100644 zSDKs/sdk-cli/internal/forms/field_confirm.go delete mode 100644 zSDKs/sdk-cli/internal/forms/field_input.go delete mode 100644 zSDKs/sdk-cli/internal/forms/field_select.go delete mode 100644 zSDKs/sdk-cli/internal/forms/field_text.go delete mode 100644 zSDKs/sdk-cli/internal/forms/form.go create mode 100644 zSDKs/sdk-cli/internal/forms/forms.go delete mode 100644 zSDKs/sdk-cli/internal/forms/group.go delete mode 100644 zSDKs/sdk-cli/internal/forms/keymap.go delete mode 100644 zSDKs/sdk-cli/internal/forms/option.go delete mode 100644 zSDKs/sdk-cli/internal/forms/selector.go delete mode 100644 zSDKs/sdk-cli/internal/forms/theme.go delete mode 100644 zSDKs/sdk-cli/internal/forms/wrap.go diff --git a/templates/perms.go b/templates/perms.go index c79bef213..2df504154 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -6,779 +6,766 @@ package templates import "io/fs" var fileMode = map[string]fs.FileMode{ - "templates/cli": 0o20000000755, - "templates/cli/.gitignore.stmpl": 0o644, - "templates/cli/CLAUDE.md": 0o644, - "templates/cli/README.md": 0o644, - "templates/cli/artifact_test.go.stmpl": 0o644, - "templates/cli/async_test.go.stmpl": 0o644, - "templates/cli/auth.go.stmpl": 0o644, - "templates/cli/auxiliary": 0o20000000755, - "templates/cli/auxiliary/cmd": 0o20000000755, - "templates/cli/auxiliary/cmd/gendocs": 0o20000000755, - "templates/cli/auxiliary/cmd/gendocs/main.go.stmpl": 0o644, - "templates/cli/auxiliary/cmd/gendocs/main_test.go.stmpl": 0o644, - "templates/cli/auxiliary/cmd/{{sanitizeCliName}}": 0o20000000755, - "templates/cli/auxiliary/cmd/{{sanitizeCliName}}/main.go.stmpl": 0o644, - "templates/cli/auxiliary/go.mod.stmpl": 0o644, - "templates/cli/auxiliary/internal": 0o20000000755, - "templates/cli/auxiliary/internal/client": 0o20000000755, - "templates/cli/auxiliary/internal/client/client.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/client/diagnostics.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/clierrors": 0o20000000755, - "templates/cli/auxiliary/internal/clierrors/clierrors.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/config": 0o20000000755, - "templates/cli/auxiliary/internal/config/config.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/config/keyring.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer": 0o20000000755, - "templates/cli/auxiliary/internal/explorer/explorer.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/styles.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/tree.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/explorer/tui.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil": 0o20000000755, - "templates/cli/auxiliary/internal/flagutil/async.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/dispatch.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms": 0o20000000755, - "templates/cli/auxiliary/internal/forms/accessible.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/accessor.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/doc.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/field_input.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/field_select.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/field_text.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/form.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/group.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/keymap.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/option.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/selector.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/theme.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/wrap.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/interactive": 0o20000000755, - "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output": 0o20000000755, - "templates/cli/auxiliary/internal/output/agentmode.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/artifact.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/async.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/binary.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/classify.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/color.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/defaulttimeout.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/errortable.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/exitcodes.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/jq.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/output.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/output_test.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/outputitems.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/paginated.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/pretty.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/progress.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/streaming.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/output/streamproject.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/testclient": 0o20000000755, - "templates/cli/auxiliary/internal/testclient/testclient.go.stmpl": 0o644, - "templates/cli/bodyschemas.go.stmpl": 0o644, - "templates/cli/catalog.go.stmpl": 0o644, - "templates/cli/cli_harness_test.go.stmpl": 0o644, - "templates/cli/config.ts": 0o644, - "templates/cli/configure.go.stmpl": 0o644, - "templates/cli/custom_register.go.stmpl": 0o644, - "templates/cli/dryrun_matrix_test.go.stmpl": 0o644, - "templates/cli/dryrun_response_test.go.stmpl": 0o644, - "templates/cli/examples.ts": 0o644, - "templates/cli/exclusions.ts": 0o644, - "templates/cli/explorer_tui_test.go.stmpl": 0o644, - "templates/cli/features.ts": 0o644, - "templates/cli/includes": 0o20000000755, - "templates/cli/includes/dependencies.ts": 0o644, - "templates/cli/includes/descriptions.ts": 0o644, - "templates/cli/includes/errors.ts": 0o644, - "templates/cli/includes/flags.ts": 0o644, - "templates/cli/includes/imports.ts": 0o644, - "templates/cli/includes/includes.ts": 0o644, - "templates/cli/includes/intents.ts": 0o644, - "templates/cli/includes/metadata.ts": 0o644, - "templates/cli/includes/readme-commands.ts": 0o644, - "templates/cli/includes/readme-examples.ts": 0o644, - "templates/cli/includes/release.ts": 0o644, - "templates/cli/includes/sanitization.ts": 0o644, - "templates/cli/includes/security.ts": 0o644, - "templates/cli/includes/serverusage.ts": 0o644, - "templates/cli/includes/templating.ts": 0o644, - "templates/cli/includes/test-assertions.ts": 0o644, - "templates/cli/includes/test-workflows.ts": 0o644, - "templates/cli/includes/tests.ts": 0o644, - "templates/cli/includes/unimplemented.ts": 0o644, - "templates/cli/includes/unions.ts": 0o644, - "templates/cli/includes/usage.ts": 0o644, - "templates/cli/includes/utils.ts": 0o644, - "templates/cli/intentcmd.go.stmpl": 0o644, - "templates/cli/intenthint_test.go.stmpl": 0o644, - "templates/cli/intents.go.stmpl": 0o644, - "templates/cli/intents_order_test.go.stmpl": 0o644, - "templates/cli/intentstream_test.go.stmpl": 0o644, - "templates/cli/interactive_policy_test.go.stmpl": 0o644, - "templates/cli/main.ts": 0o644, - "templates/cli/masking.go.stmpl": 0o644, - "templates/cli/mockserver.ts": 0o644, - "templates/cli/opcmd.go.stmpl": 0o644, - "templates/cli/opstream_test.go.stmpl": 0o644, - "templates/cli/positional_test.go.stmpl": 0o644, - "templates/cli/readme": 0o20000000755, - "templates/cli/readme.stmpl": 0o644, - "templates/cli/readme/agents.stmpl": 0o644, - "templates/cli/readme/completion.stmpl": 0o644, - "templates/cli/readme/diagnostics.stmpl": 0o644, - "templates/cli/readme/errors.stmpl": 0o644, - "templates/cli/readme/eventstreaming.stmpl": 0o644, - "templates/cli/readme/fileuploads.stmpl": 0o644, - "templates/cli/readme/globals.stmpl": 0o644, - "templates/cli/readme/installation.stmpl": 0o644, - "templates/cli/readme/jsonlstreaming.stmpl": 0o644, - "templates/cli/readme/output.stmpl": 0o644, - "templates/cli/readme/pagination.stmpl": 0o644, - "templates/cli/readme/retries.stmpl": 0o644, - "templates/cli/readme/security.stmpl": 0o644, - "templates/cli/readme/server.stmpl": 0o644, - "templates/cli/readme/stdinpiping.stmpl": 0o644, - "templates/cli/readme_examples_test.go.stmpl": 0o644, - "templates/cli/release": 0o20000000755, - "templates/cli/release/install.ps1.stmpl": 0o644, - "templates/cli/release/install.sh.stmpl": 0o644, - "templates/cli/release/release-workflow.yaml.stmpl": 0o644, - "templates/cli/request_progress_test.go.stmpl": 0o644, - "templates/cli/root.go.stmpl": 0o644, - "templates/cli/standalone.ts": 0o644, - "templates/cli/startup_tty_test.go.stmpl": 0o644, - "templates/cli/subroot.go.stmpl": 0o644, - "templates/cli/test.go.stmpl": 0o644, - "templates/cli/testfile.go.stmpl": 0o644, - "templates/cli/testhelper.go.stmpl": 0o644, - "templates/cli/testhelpers.go.stmpl": 0o644, - "templates/cli/tests": 0o20000000755, - "templates/cli/tests/basic-http": 0o20000000755, - "templates/cli/tests/basic-http/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/basic-http/serverflag_auto_additional_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials": 0o20000000755, - "templates/cli/tests/client-credentials-basic": 0o20000000755, - "templates/cli/tests/client-credentials-basic/serverflag_hidden_additional_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/dryrun_client_credentials_test.go.stmpl": 0o644, - "templates/cli/tests/client-credentials/serverflag_none_additional_test.go.stmpl": 0o644, - "templates/cli/tests/common": 0o20000000755, - "templates/cli/tests/common/common_helpers_test.go": 0o644, - "templates/cli/tests/common/echo_server_test.go": 0o644, - "templates/cli/tests/custom-http": 0o20000000755, - "templates/cli/tests/custom-http/auth_test.go.stmpl": 0o644, - "templates/cli/tests/custom-http/defaulttimeout_test.go.stmpl": 0o644, - "templates/cli/tests/custom-http/serverflag_default_additional_test.go.stmpl": 0o644, - "templates/cli/tests/no-servers": 0o20000000755, - "templates/cli/tests/no-servers/no_servers_test.go.stmpl": 0o644, - "templates/cli/tests/oauth2-password": 0o20000000755, - "templates/cli/tests/oauth2-password/oauth2_password_hook_test.go.stmpl": 0o644, - "templates/cli/tests/oauth2-password/serverflag_visible_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary": 0o20000000755, - "templates/cli/tests/primary/agentmode_test.go.stmpl": 0o644, - "templates/cli/tests/primary/arghygiene_test.go.stmpl": 0o644, - "templates/cli/tests/primary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/auth_login_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/bodyinput_test.go.stmpl": 0o644, - "templates/cli/tests/primary/catalog_test.go.stmpl": 0o644, - "templates/cli/tests/primary/clierrors_test.go.stmpl": 0o644, - "templates/cli/tests/primary/collections_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/compact_help_test.go.stmpl": 0o644, - "templates/cli/tests/primary/configure_test.go.stmpl": 0o644, - "templates/cli/tests/primary/custom_commands_test.go.stmpl": 0o644, - "templates/cli/tests/primary/diagnostics_test.go.stmpl": 0o644, - "templates/cli/tests/primary/dispatch_test.go.stmpl": 0o644, - "templates/cli/tests/primary/dryrun_matrix_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/enums_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/errors_test.go.stmpl": 0o644, - "templates/cli/tests/primary/errortaxonomy_test.go.stmpl": 0o644, - "templates/cli/tests/primary/eventstreaming_test.go.stmpl": 0o644, - "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, - "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, - "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, - "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/intents_test.go.stmpl": 0o644, - "templates/cli/tests/primary/jsonlstreaming_test.go.stmpl": 0o644, - "templates/cli/tests/primary/keyring_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/keyring_timeout_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/methods_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/pagination_test.go.stmpl": 0o644, - "templates/cli/tests/primary/parameter_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/polling_test.go.stmpl": 0o644, - "templates/cli/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/requestshape_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/primary/responselifetime_test.go.stmpl": 0o644, - "templates/cli/tests/primary/retries_test.go.stmpl": 0o644, - "templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644, - "templates/cli/tests/primary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644, - "templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644, - "templates/cli/tests/primary/toon_test.go.stmpl": 0o644, - "templates/cli/tests/primary/unions_additional_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary": 0o20000000755, - "templates/cli/tests/quaternary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/quaternary/transform_test.go.stmpl": 0o644, - "templates/cli/tests/secondary": 0o20000000755, - "templates/cli/tests/secondary/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/pagination_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/redirects_test.go.stmpl": 0o644, - "templates/cli/tests/secondary/servers_test.go.stmpl": 0o644, - "templates/cli/tests/security-options": 0o20000000755, - "templates/cli/tests/security-options/auth_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary": 0o20000000755, - "templates/cli/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/parameters_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/retry_method_policy_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/retryflags_visibility_additional_test.go.stmpl": 0o644, - "templates/cli/tests/tertiary/servers_test.go.stmpl": 0o644, - "templates/cli/testusage": 0o20000000755, - "templates/cli/testusage/go.mod.stmpl": 0o644, - "templates/cli/tsconfig.json": 0o644, - "templates/cli/usage": 0o20000000755, - "templates/cli/usage.go.stmpl": 0o644, - "templates/cli/usage/snippet.stmpl": 0o644, - "templates/cli/usage_test.go.stmpl": 0o644, - "templates/cli/version.go.stmpl": 0o644, - "templates/cli/whoami.go.stmpl": 0o644, - "templates/common": 0o20000000755, - "templates/common/HIDDEN": 0o644, - "templates/common/common": 0o20000000755, - "templates/common/common/annotations.ts": 0o644, - "templates/common/common/common.d.ts": 0o644, - "templates/common/common/config.ts": 0o644, - "templates/common/common/constants.ts": 0o644, - "templates/common/common/datatypes.ts": 0o644, - "templates/common/common/diff.ts": 0o644, - "templates/common/common/documentation.ts": 0o644, - "templates/common/common/envvars.ts": 0o644, - "templates/common/common/errors.ts": 0o644, - "templates/common/common/eventstreaming.ts": 0o644, - "templates/common/common/examples.ts": 0o644, - "templates/common/common/extensions.ts": 0o644, - "templates/common/common/faker.ts": 0o644, - "templates/common/common/features.d.ts": 0o644, - "templates/common/common/fieldDefs.ts": 0o644, - "templates/common/common/gitignore.ts": 0o644, - "templates/common/common/imports.ts": 0o644, - "templates/common/common/includes.ts": 0o644, - "templates/common/common/models.ts": 0o644, - "templates/common/common/modelusage.ts": 0o644, - "templates/common/common/open-unions.ts": 0o644, - "templates/common/common/operations.ts": 0o644, - "templates/common/common/polyfill.ts": 0o644, - "templates/common/common/sanitization.ts": 0o644, - "templates/common/common/sdkdocs.ts": 0o644, - "templates/common/common/security.ts": 0o644, - "templates/common/common/securityusage.ts": 0o644, - "templates/common/common/serverusage.ts": 0o644, - "templates/common/common/templating.ts": 0o644, - "templates/common/common/tests.ts": 0o644, - "templates/common/common/tsconfig.json": 0o644, - "templates/common/common/typeDefs.d.ts": 0o644, - "templates/common/common/typematching.ts": 0o644, - "templates/common/common/types.ts": 0o644, - "templates/common/common/utils.ts": 0o644, - "templates/common/common/webhooks.ts": 0o644, - "templates/common/containerReadme.stmpl": 0o644, - "templates/common/containerSetup.stmpl": 0o644, - "templates/common/contributing.md.stmpl": 0o644, - "templates/common/example.file.stmpl": 0o644, - "templates/common/generated-license": 0o20000000755, - "templates/common/generated-license/LICENSE": 0o644, - "templates/common/generated-license/NOTICE.stmpl": 0o644, - "templates/common/gitattributes.stmpl": 0o644, - "templates/common/go": 0o20000000755, - "templates/common/go/annotations.ts": 0o644, - "templates/common/go/discriminators.ts": 0o644, - "templates/common/go/getter_sanitization.ts": 0o644, - "templates/common/go/includes.ts": 0o644, - "templates/common/go/sanitization.ts": 0o644, - "templates/common/mcp": 0o20000000755, - "templates/common/mcp/readme": 0o20000000755, - "templates/common/mcp/readme/includes": 0o20000000755, - "templates/common/mcp/readme/includes/includes.ts": 0o644, - "templates/common/mcp/readme/includes/sections.ts": 0o644, - "templates/common/mcp/readme/sections": 0o20000000755, - "templates/common/mcp/readme/sections/common.ts": 0o644, - "templates/common/readme": 0o20000000755, - "templates/common/readme.stmpl": 0o644, - "templates/common/readme/dev-containers.stmpl": 0o644, - "templates/common/readme/globals.stmpl": 0o644, - "templates/common/readme/header.stmpl": 0o644, - "templates/common/readme/includes": 0o20000000755, - "templates/common/readme/includes/globals.ts": 0o644, - "templates/common/readme/includes/header.ts": 0o644, - "templates/common/readme/includes/includes.ts": 0o644, - "templates/common/readme/includes/operations.ts": 0o644, - "templates/common/readme/includes/readme.ts": 0o644, - "templates/common/readme/includes/sections.ts": 0o644, - "templates/common/readme/includes/toc.ts": 0o644, - "templates/common/readme/includes/usage.ts": 0o644, - "templates/common/readme/model.stmpl": 0o644, - "templates/common/readme/operation.example_after.stmpl": 0o644, - "templates/common/readme/operation.stmpl": 0o644, - "templates/common/readme/sdk.stmpl": 0o644, - "templates/common/readme/sections": 0o20000000755, - "templates/common/readme/sections/common.ts": 0o644, - "templates/common/readme/sections/debug.ts": 0o644, - "templates/common/readme/sections/errors.ts": 0o644, - "templates/common/readme/sections/eventstream.ts": 0o644, - "templates/common/readme/sections/file-upload.ts": 0o644, - "templates/common/readme/sections/http-client.ts": 0o644, - "templates/common/readme/sections/jsonl.ts": 0o644, - "templates/common/readme/sections/pagination.ts": 0o644, - "templates/common/readme/sections/react-query.ts": 0o644, - "templates/common/readme/sections/requirements.ts": 0o644, - "templates/common/readme/sections/retries.ts": 0o644, - "templates/common/readme/sections/security.ts": 0o644, - "templates/common/readme/sections/server.ts": 0o644, - "templates/common/readme/sections/standalone-funcs.ts": 0o644, - "templates/common/readme/server.stmpl": 0o644, - "templates/common/readme/snippet.stmpl": 0o644, - "templates/common/readme/summary.stmpl": 0o644, - "templates/common/readme/usage-container.stmpl": 0o644, - "templates/common/readme/usage.stmpl": 0o644, - "templates/common/typescript": 0o20000000755, - "templates/common/typescript/includes.ts": 0o644, - "templates/common/typescript/sanitization.ts": 0o644, - "templates/common/typescript/security.ts": 0o644, - "templates/common/usage": 0o20000000755, - "templates/common/usage.stmpl": 0o644, - "templates/common/usage/model_snippet.stmpl": 0o644, - "templates/csharp": 0o20000000755, - "templates/csharp/.csharpierignore": 0o644, - "templates/csharp/.gitignore.stmpl": 0o644, - "templates/csharp/auxiliary": 0o20000000755, - "templates/csharp/auxiliary/global.json.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getPackageId }}.sln.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/AnyDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/BigIntStrConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Constants.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/DecimalStrConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/EnumConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/FlexibleObjectDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/HeaderSerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenEnum.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenUnion.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IsoDateTimeSerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OpenEnumConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullable.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullableJsonConverter.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/RequestBodySerializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/ResponseBodyDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/BackoffStrategy.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/Retries.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/RetryConfig.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SecurityMetadata.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SpeakeasyMetadata.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/EventStream.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseEvent.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseParseException.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamExtensions.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamParser.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/URLBuilder.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/UnionDeserializer.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Utilities.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/{{getDefaultHttpClient}}.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{ getPackageId }}.csproj.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}": 0o20000000755, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/ResponseValidationException.cs.stmpl": 0o644, - "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getBaseErrorClassName}}.cs.stmpl": 0o644, + "templates/cli": 0o20000000755, + "templates/cli/.gitignore.stmpl": 0o644, + "templates/cli/CLAUDE.md": 0o644, + "templates/cli/README.md": 0o644, + "templates/cli/artifact_test.go.stmpl": 0o644, + "templates/cli/async_test.go.stmpl": 0o644, + "templates/cli/auth.go.stmpl": 0o644, + "templates/cli/auxiliary": 0o20000000755, + "templates/cli/auxiliary/cmd": 0o20000000755, + "templates/cli/auxiliary/cmd/gendocs": 0o20000000755, + "templates/cli/auxiliary/cmd/gendocs/main.go.stmpl": 0o644, + "templates/cli/auxiliary/cmd/gendocs/main_test.go.stmpl": 0o644, + "templates/cli/auxiliary/cmd/{{sanitizeCliName}}": 0o20000000755, + "templates/cli/auxiliary/cmd/{{sanitizeCliName}}/main.go.stmpl": 0o644, + "templates/cli/auxiliary/go.mod.stmpl": 0o644, + "templates/cli/auxiliary/internal": 0o20000000755, + "templates/cli/auxiliary/internal/client": 0o20000000755, + "templates/cli/auxiliary/internal/client/client.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/client/diagnostics.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/clierrors": 0o20000000755, + "templates/cli/auxiliary/internal/clierrors/clierrors.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/config": 0o20000000755, + "templates/cli/auxiliary/internal/config/config.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/config/keyring.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer": 0o20000000755, + "templates/cli/auxiliary/internal/explorer/explorer.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/styles.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/tree.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/explorer/tui.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil": 0o20000000755, + "templates/cli/auxiliary/internal/flagutil/async.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/dispatch.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/flags.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/metadata.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms": 0o20000000755, + "templates/cli/auxiliary/internal/forms/forms.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/interactive": 0o20000000755, + "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output": 0o20000000755, + "templates/cli/auxiliary/internal/output/agentmode.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/artifact.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/async.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/binary.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/classify.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/color.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/defaulttimeout.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/errortable.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/exitcodes.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/jq.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/output.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/output_test.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/outputitems.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/paginated.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/pretty.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/progress.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/streaming.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/output/streamproject.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/testclient": 0o20000000755, + "templates/cli/auxiliary/internal/testclient/testclient.go.stmpl": 0o644, + "templates/cli/bodyschemas.go.stmpl": 0o644, + "templates/cli/catalog.go.stmpl": 0o644, + "templates/cli/cli_harness_test.go.stmpl": 0o644, + "templates/cli/config.ts": 0o644, + "templates/cli/configure.go.stmpl": 0o644, + "templates/cli/custom_register.go.stmpl": 0o644, + "templates/cli/dryrun_matrix_test.go.stmpl": 0o644, + "templates/cli/dryrun_response_test.go.stmpl": 0o644, + "templates/cli/examples.ts": 0o644, + "templates/cli/exclusions.ts": 0o644, + "templates/cli/explorer_tui_test.go.stmpl": 0o644, + "templates/cli/features.ts": 0o644, + "templates/cli/includes": 0o20000000755, + "templates/cli/includes/dependencies.ts": 0o644, + "templates/cli/includes/descriptions.ts": 0o644, + "templates/cli/includes/errors.ts": 0o644, + "templates/cli/includes/flags.ts": 0o644, + "templates/cli/includes/imports.ts": 0o644, + "templates/cli/includes/includes.ts": 0o644, + "templates/cli/includes/intents.ts": 0o644, + "templates/cli/includes/metadata.ts": 0o644, + "templates/cli/includes/readme-commands.ts": 0o644, + "templates/cli/includes/readme-examples.ts": 0o644, + "templates/cli/includes/release.ts": 0o644, + "templates/cli/includes/sanitization.ts": 0o644, + "templates/cli/includes/security.ts": 0o644, + "templates/cli/includes/serverusage.ts": 0o644, + "templates/cli/includes/templating.ts": 0o644, + "templates/cli/includes/test-assertions.ts": 0o644, + "templates/cli/includes/test-workflows.ts": 0o644, + "templates/cli/includes/tests.ts": 0o644, + "templates/cli/includes/unimplemented.ts": 0o644, + "templates/cli/includes/unions.ts": 0o644, + "templates/cli/includes/usage.ts": 0o644, + "templates/cli/includes/utils.ts": 0o644, + "templates/cli/intentcmd.go.stmpl": 0o644, + "templates/cli/intenthint_test.go.stmpl": 0o644, + "templates/cli/intents.go.stmpl": 0o644, + "templates/cli/intents_order_test.go.stmpl": 0o644, + "templates/cli/intentstream_test.go.stmpl": 0o644, + "templates/cli/interactive_policy_test.go.stmpl": 0o644, + "templates/cli/main.ts": 0o644, + "templates/cli/masking.go.stmpl": 0o644, + "templates/cli/mockserver.ts": 0o644, + "templates/cli/opcmd.go.stmpl": 0o644, + "templates/cli/opstream_test.go.stmpl": 0o644, + "templates/cli/positional_test.go.stmpl": 0o644, + "templates/cli/readme": 0o20000000755, + "templates/cli/readme.stmpl": 0o644, + "templates/cli/readme/agents.stmpl": 0o644, + "templates/cli/readme/completion.stmpl": 0o644, + "templates/cli/readme/diagnostics.stmpl": 0o644, + "templates/cli/readme/errors.stmpl": 0o644, + "templates/cli/readme/eventstreaming.stmpl": 0o644, + "templates/cli/readme/fileuploads.stmpl": 0o644, + "templates/cli/readme/globals.stmpl": 0o644, + "templates/cli/readme/installation.stmpl": 0o644, + "templates/cli/readme/jsonlstreaming.stmpl": 0o644, + "templates/cli/readme/output.stmpl": 0o644, + "templates/cli/readme/pagination.stmpl": 0o644, + "templates/cli/readme/retries.stmpl": 0o644, + "templates/cli/readme/security.stmpl": 0o644, + "templates/cli/readme/server.stmpl": 0o644, + "templates/cli/readme/stdinpiping.stmpl": 0o644, + "templates/cli/readme_examples_test.go.stmpl": 0o644, + "templates/cli/release": 0o20000000755, + "templates/cli/release/install.ps1.stmpl": 0o644, + "templates/cli/release/install.sh.stmpl": 0o644, + "templates/cli/release/release-workflow.yaml.stmpl": 0o644, + "templates/cli/request_progress_test.go.stmpl": 0o644, + "templates/cli/root.go.stmpl": 0o644, + "templates/cli/standalone.ts": 0o644, + "templates/cli/startup_tty_test.go.stmpl": 0o644, + "templates/cli/subroot.go.stmpl": 0o644, + "templates/cli/test.go.stmpl": 0o644, + "templates/cli/testfile.go.stmpl": 0o644, + "templates/cli/testhelper.go.stmpl": 0o644, + "templates/cli/testhelpers.go.stmpl": 0o644, + "templates/cli/tests": 0o20000000755, + "templates/cli/tests/basic-http": 0o20000000755, + "templates/cli/tests/basic-http/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/basic-http/serverflag_auto_additional_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials": 0o20000000755, + "templates/cli/tests/client-credentials-basic": 0o20000000755, + "templates/cli/tests/client-credentials-basic/serverflag_hidden_additional_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/dryrun_client_credentials_test.go.stmpl": 0o644, + "templates/cli/tests/client-credentials/serverflag_none_additional_test.go.stmpl": 0o644, + "templates/cli/tests/common": 0o20000000755, + "templates/cli/tests/common/common_helpers_test.go": 0o644, + "templates/cli/tests/common/echo_server_test.go": 0o644, + "templates/cli/tests/custom-http": 0o20000000755, + "templates/cli/tests/custom-http/auth_test.go.stmpl": 0o644, + "templates/cli/tests/custom-http/defaulttimeout_test.go.stmpl": 0o644, + "templates/cli/tests/custom-http/serverflag_default_additional_test.go.stmpl": 0o644, + "templates/cli/tests/no-servers": 0o20000000755, + "templates/cli/tests/no-servers/no_servers_test.go.stmpl": 0o644, + "templates/cli/tests/oauth2-password": 0o20000000755, + "templates/cli/tests/oauth2-password/oauth2_password_hook_test.go.stmpl": 0o644, + "templates/cli/tests/oauth2-password/serverflag_visible_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary": 0o20000000755, + "templates/cli/tests/primary/agentmode_test.go.stmpl": 0o644, + "templates/cli/tests/primary/arghygiene_test.go.stmpl": 0o644, + "templates/cli/tests/primary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/auth_login_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/bodyinput_test.go.stmpl": 0o644, + "templates/cli/tests/primary/catalog_test.go.stmpl": 0o644, + "templates/cli/tests/primary/clierrors_test.go.stmpl": 0o644, + "templates/cli/tests/primary/collections_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/compact_help_test.go.stmpl": 0o644, + "templates/cli/tests/primary/configure_test.go.stmpl": 0o644, + "templates/cli/tests/primary/custom_commands_test.go.stmpl": 0o644, + "templates/cli/tests/primary/diagnostics_test.go.stmpl": 0o644, + "templates/cli/tests/primary/dispatch_test.go.stmpl": 0o644, + "templates/cli/tests/primary/dryrun_matrix_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/enums_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/errors_test.go.stmpl": 0o644, + "templates/cli/tests/primary/errortaxonomy_test.go.stmpl": 0o644, + "templates/cli/tests/primary/eventstreaming_test.go.stmpl": 0o644, + "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, + "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, + "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/intents_test.go.stmpl": 0o644, + "templates/cli/tests/primary/jsonlstreaming_test.go.stmpl": 0o644, + "templates/cli/tests/primary/keyring_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/keyring_timeout_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/methods_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/pagination_test.go.stmpl": 0o644, + "templates/cli/tests/primary/parameter_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/polling_test.go.stmpl": 0o644, + "templates/cli/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/requestshape_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/primary/responselifetime_test.go.stmpl": 0o644, + "templates/cli/tests/primary/retries_test.go.stmpl": 0o644, + "templates/cli/tests/primary/security_ranking_test.go.stmpl": 0o644, + "templates/cli/tests/primary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/primary/stringconstraints_test.go.stmpl": 0o644, + "templates/cli/tests/primary/telemetry_test.go.stmpl": 0o644, + "templates/cli/tests/primary/toon_test.go.stmpl": 0o644, + "templates/cli/tests/primary/unions_additional_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary": 0o20000000755, + "templates/cli/tests/quaternary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/quaternary/transform_test.go.stmpl": 0o644, + "templates/cli/tests/secondary": 0o20000000755, + "templates/cli/tests/secondary/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/pagination_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/redirects_test.go.stmpl": 0o644, + "templates/cli/tests/secondary/servers_test.go.stmpl": 0o644, + "templates/cli/tests/security-options": 0o20000000755, + "templates/cli/tests/security-options/auth_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary": 0o20000000755, + "templates/cli/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/parameters_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/retry_method_policy_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/retryflags_visibility_additional_test.go.stmpl": 0o644, + "templates/cli/tests/tertiary/servers_test.go.stmpl": 0o644, + "templates/cli/testusage": 0o20000000755, + "templates/cli/testusage/go.mod.stmpl": 0o644, + "templates/cli/tsconfig.json": 0o644, + "templates/cli/usage": 0o20000000755, + "templates/cli/usage.go.stmpl": 0o644, + "templates/cli/usage/snippet.stmpl": 0o644, + "templates/cli/usage_test.go.stmpl": 0o644, + "templates/cli/version.go.stmpl": 0o644, + "templates/cli/whoami.go.stmpl": 0o644, + "templates/common": 0o20000000755, + "templates/common/HIDDEN": 0o644, + "templates/common/common": 0o20000000755, + "templates/common/common/annotations.ts": 0o644, + "templates/common/common/common.d.ts": 0o644, + "templates/common/common/config.ts": 0o644, + "templates/common/common/constants.ts": 0o644, + "templates/common/common/datatypes.ts": 0o644, + "templates/common/common/diff.ts": 0o644, + "templates/common/common/documentation.ts": 0o644, + "templates/common/common/envvars.ts": 0o644, + "templates/common/common/errors.ts": 0o644, + "templates/common/common/eventstreaming.ts": 0o644, + "templates/common/common/examples.ts": 0o644, + "templates/common/common/extensions.ts": 0o644, + "templates/common/common/faker.ts": 0o644, + "templates/common/common/features.d.ts": 0o644, + "templates/common/common/fieldDefs.ts": 0o644, + "templates/common/common/gitignore.ts": 0o644, + "templates/common/common/imports.ts": 0o644, + "templates/common/common/includes.ts": 0o644, + "templates/common/common/models.ts": 0o644, + "templates/common/common/modelusage.ts": 0o644, + "templates/common/common/open-unions.ts": 0o644, + "templates/common/common/operations.ts": 0o644, + "templates/common/common/polyfill.ts": 0o644, + "templates/common/common/sanitization.ts": 0o644, + "templates/common/common/sdkdocs.ts": 0o644, + "templates/common/common/security.ts": 0o644, + "templates/common/common/securityusage.ts": 0o644, + "templates/common/common/serverusage.ts": 0o644, + "templates/common/common/templating.ts": 0o644, + "templates/common/common/tests.ts": 0o644, + "templates/common/common/tsconfig.json": 0o644, + "templates/common/common/typeDefs.d.ts": 0o644, + "templates/common/common/typematching.ts": 0o644, + "templates/common/common/types.ts": 0o644, + "templates/common/common/utils.ts": 0o644, + "templates/common/common/webhooks.ts": 0o644, + "templates/common/containerReadme.stmpl": 0o644, + "templates/common/containerSetup.stmpl": 0o644, + "templates/common/contributing.md.stmpl": 0o644, + "templates/common/example.file.stmpl": 0o644, + "templates/common/generated-license": 0o20000000755, + "templates/common/generated-license/LICENSE": 0o644, + "templates/common/generated-license/NOTICE.stmpl": 0o644, + "templates/common/gitattributes.stmpl": 0o644, + "templates/common/go": 0o20000000755, + "templates/common/go/annotations.ts": 0o644, + "templates/common/go/discriminators.ts": 0o644, + "templates/common/go/getter_sanitization.ts": 0o644, + "templates/common/go/includes.ts": 0o644, + "templates/common/go/sanitization.ts": 0o644, + "templates/common/mcp": 0o20000000755, + "templates/common/mcp/readme": 0o20000000755, + "templates/common/mcp/readme/includes": 0o20000000755, + "templates/common/mcp/readme/includes/includes.ts": 0o644, + "templates/common/mcp/readme/includes/sections.ts": 0o644, + "templates/common/mcp/readme/sections": 0o20000000755, + "templates/common/mcp/readme/sections/common.ts": 0o644, + "templates/common/readme": 0o20000000755, + "templates/common/readme.stmpl": 0o644, + "templates/common/readme/dev-containers.stmpl": 0o644, + "templates/common/readme/globals.stmpl": 0o644, + "templates/common/readme/header.stmpl": 0o644, + "templates/common/readme/includes": 0o20000000755, + "templates/common/readme/includes/globals.ts": 0o644, + "templates/common/readme/includes/header.ts": 0o644, + "templates/common/readme/includes/includes.ts": 0o644, + "templates/common/readme/includes/operations.ts": 0o644, + "templates/common/readme/includes/readme.ts": 0o644, + "templates/common/readme/includes/sections.ts": 0o644, + "templates/common/readme/includes/toc.ts": 0o644, + "templates/common/readme/includes/usage.ts": 0o644, + "templates/common/readme/model.stmpl": 0o644, + "templates/common/readme/operation.example_after.stmpl": 0o644, + "templates/common/readme/operation.stmpl": 0o644, + "templates/common/readme/sdk.stmpl": 0o644, + "templates/common/readme/sections": 0o20000000755, + "templates/common/readme/sections/common.ts": 0o644, + "templates/common/readme/sections/debug.ts": 0o644, + "templates/common/readme/sections/errors.ts": 0o644, + "templates/common/readme/sections/eventstream.ts": 0o644, + "templates/common/readme/sections/file-upload.ts": 0o644, + "templates/common/readme/sections/http-client.ts": 0o644, + "templates/common/readme/sections/jsonl.ts": 0o644, + "templates/common/readme/sections/pagination.ts": 0o644, + "templates/common/readme/sections/react-query.ts": 0o644, + "templates/common/readme/sections/requirements.ts": 0o644, + "templates/common/readme/sections/retries.ts": 0o644, + "templates/common/readme/sections/security.ts": 0o644, + "templates/common/readme/sections/server.ts": 0o644, + "templates/common/readme/sections/standalone-funcs.ts": 0o644, + "templates/common/readme/server.stmpl": 0o644, + "templates/common/readme/snippet.stmpl": 0o644, + "templates/common/readme/summary.stmpl": 0o644, + "templates/common/readme/usage-container.stmpl": 0o644, + "templates/common/readme/usage.stmpl": 0o644, + "templates/common/typescript": 0o20000000755, + "templates/common/typescript/includes.ts": 0o644, + "templates/common/typescript/sanitization.ts": 0o644, + "templates/common/typescript/security.ts": 0o644, + "templates/common/usage": 0o20000000755, + "templates/common/usage.stmpl": 0o644, + "templates/common/usage/model_snippet.stmpl": 0o644, + "templates/csharp": 0o20000000755, + "templates/csharp/.csharpierignore": 0o644, + "templates/csharp/.gitignore.stmpl": 0o644, + "templates/csharp/auxiliary": 0o20000000755, + "templates/csharp/auxiliary/global.json.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getPackageId }}.sln.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/AnyDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/BigIntStrConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Constants.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/DecimalStrConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/EnumConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/FlexibleObjectDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/HeaderSerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenEnum.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IOpenUnion.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/IsoDateTimeSerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OpenEnumConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullable.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/OptionalNullableJsonConverter.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/RequestBodySerializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/ResponseBodyDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/BackoffStrategy.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/Retries.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Retries/RetryConfig.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SecurityMetadata.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/SpeakeasyMetadata.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/EventStream.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseEvent.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseParseException.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamExtensions.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Sse/SseStreamParser.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/URLBuilder.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/UnionDeserializer.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/Utilities.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/Utils/{{getDefaultHttpClient}}.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{ getPackageId }}.csproj.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}": 0o20000000755, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/ResponseValidationException.cs.stmpl": 0o644, + "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getBaseErrorClassName}}.cs.stmpl": 0o644, "templates/csharp/auxiliary/{{ getSDKTopLevelFolder }}/{{getErrorsLocation}}/{{getDefaultErrorClassName}}.cs.stmpl": 0o644, - "templates/csharp/config.ts": 0o644, - "templates/csharp/enum-extension.cs.stmpl": 0o644, - "templates/csharp/enum-open.cs.stmpl": 0o644, - "templates/csharp/enum.cs.stmpl": 0o644, - "templates/csharp/error.cs.stmpl": 0o644, - "templates/csharp/examples.ts": 0o644, - "templates/csharp/exclusions.ts": 0o644, - "templates/csharp/features.ts": 0o644, - "templates/csharp/hooks": 0o20000000755, - "templates/csharp/hooks/clientcredentials.cs.stmpl": 0o644, - "templates/csharp/hooks/hooks.cs.stmpl": 0o644, - "templates/csharp/hooks/hooks.ts": 0o644, - "templates/csharp/hooks/oauth2scopes.cs.stmpl": 0o644, - "templates/csharp/hooks/registration.cs.stmpl": 0o644, - "templates/csharp/hooks/types.cs.stmpl": 0o644, - "templates/csharp/includes": 0o20000000755, - "templates/csharp/includes/annotations.ts": 0o644, - "templates/csharp/includes/auxiliary.ts": 0o644, - "templates/csharp/includes/compat.ts": 0o644, - "templates/csharp/includes/dependencies.ts": 0o644, - "templates/csharp/includes/documentation.ts": 0o644, - "templates/csharp/includes/enums.ts": 0o644, - "templates/csharp/includes/errors.ts": 0o644, - "templates/csharp/includes/imports.ts": 0o644, - "templates/csharp/includes/includes.ts": 0o644, - "templates/csharp/includes/models.ts": 0o644, - "templates/csharp/includes/modelusage.ts": 0o644, - "templates/csharp/includes/namespaces.ts": 0o644, - "templates/csharp/includes/nuget.ts": 0o644, - "templates/csharp/includes/open-unions.ts": 0o644, - "templates/csharp/includes/pagination.ts": 0o644, - "templates/csharp/includes/responses.ts": 0o644, - "templates/csharp/includes/sanitization.ts": 0o644, - "templates/csharp/includes/security.ts": 0o644, - "templates/csharp/includes/serverusage.ts": 0o644, - "templates/csharp/includes/templating.ts": 0o644, - "templates/csharp/includes/tests.ts": 0o644, - "templates/csharp/includes/unimplemented.ts": 0o644, - "templates/csharp/includes/utils.ts": 0o644, - "templates/csharp/main.ts": 0o644, - "templates/csharp/method.cs.stmpl": 0o644, - "templates/csharp/mockserver.ts": 0o644, - "templates/csharp/model.cs.stmpl": 0o644, - "templates/csharp/modelfile.cs.stmpl": 0o644, - "templates/csharp/pagination.cs.stmpl": 0o644, - "templates/csharp/readme": 0o20000000755, - "templates/csharp/readme/errors.stmpl": 0o644, - "templates/csharp/readme/eventstream.stmpl": 0o644, - "templates/csharp/readme/http_client.stmpl": 0o644, - "templates/csharp/readme/installation.stmpl": 0o644, - "templates/csharp/readme/nuget.stmpl": 0o644, - "templates/csharp/readme/pagination.stmpl": 0o644, - "templates/csharp/readme/retries.stmpl": 0o644, - "templates/csharp/readme/security.stmpl": 0o644, - "templates/csharp/sdk.cs.stmpl": 0o644, - "templates/csharp/sdkconfig.cs.stmpl": 0o644, - "templates/csharp/servers.cs.stmpl": 0o644, - "templates/csharp/standalone.ts": 0o644, - "templates/csharp/subsdk.cs.stmpl": 0o644, - "templates/csharp/test.cs.stmpl": 0o644, - "templates/csharp/testfile.cs.stmpl": 0o644, - "templates/csharp/testhelper.cs.stmpl": 0o644, - "templates/csharp/testhelpers.cs.stmpl": 0o644, - "templates/csharp/testproject": 0o20000000755, - "templates/csharp/testproject/Program.cs.stmpl": 0o644, - "templates/csharp/testproject/testproject.csproj.stmpl": 0o644, - "templates/csharp/tests": 0o20000000755, - "templates/csharp/tests/Common": 0o20000000755, - "templates/csharp/tests/Common/CommonHelpers.cs.stmpl": 0o644, - "templates/csharp/tests/Common/Tests.csproj.stmpl": 0o644, - "templates/csharp/tests/basic-http": 0o20000000755, - "templates/csharp/tests/basic-http/AuthAdditionalTests.cs": 0o644, - "templates/csharp/tests/client-credentials": 0o20000000755, - "templates/csharp/tests/client-credentials-basic": 0o20000000755, - "templates/csharp/tests/client-credentials-basic/ClientCredentialsBasicHookShould.cs": 0o644, - "templates/csharp/tests/client-credentials/ClientCredentialsHookShould.cs": 0o644, - "templates/csharp/tests/custom-http": 0o20000000755, - "templates/csharp/tests/custom-http/AuthShould.cs": 0o644, - "templates/csharp/tests/primary": 0o20000000755, - "templates/csharp/tests/primary/AuthShould.cs": 0o644, - "templates/csharp/tests/primary/CancellationTokenShould.cs": 0o644, - "templates/csharp/tests/primary/CollectionsShould.cs": 0o644, - "templates/csharp/tests/primary/CustomClientShould.cs": 0o644, - "templates/csharp/tests/primary/ErrorsShould.cs": 0o644, - "templates/csharp/tests/primary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/FlatteningShould.cs": 0o644, - "templates/csharp/tests/primary/GlobalsAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/HeadersShould.cs": 0o644, - "templates/csharp/tests/primary/Helpers.cs": 0o644, - "templates/csharp/tests/primary/HooksShould.cs.stmpl": 0o644, - "templates/csharp/tests/primary/MultiLevelShould.cs": 0o644, - "templates/csharp/tests/primary/OpenEnumsShould.cs": 0o644, - "templates/csharp/tests/primary/OpenUnionsShould.cs": 0o644, - "templates/csharp/tests/primary/OptionalNullableFieldsShould.cs": 0o644, - "templates/csharp/tests/primary/PaginationShould.cs": 0o644, - "templates/csharp/tests/primary/ParametersShould.cs": 0o644, - "templates/csharp/tests/primary/RequestBodiesShould.cs": 0o644, - "templates/csharp/tests/primary/ResponseBodiesShould.cs": 0o644, - "templates/csharp/tests/primary/RetriesShould.cs": 0o644, - "templates/csharp/tests/primary/ServersShould.cs": 0o644, - "templates/csharp/tests/primary/SseStreamParserAdditionalShould.cs": 0o644, - "templates/csharp/tests/primary/TelemetryShould.cs.stmpl": 0o644, - "templates/csharp/tests/primary/UnionsShould.cs": 0o644, - "templates/csharp/tests/primary/testUpload.json": 0o644, - "templates/csharp/tests/quaternary": 0o20000000755, - "templates/csharp/tests/quaternary/AuthShould.cs": 0o644, - "templates/csharp/tests/quaternary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/quaternary/Helpers.cs": 0o644, - "templates/csharp/tests/quaternary/OpenEnumsShould.cs": 0o644, - "templates/csharp/tests/quaternary/OptionalNullableFieldsShould.cs": 0o644, - "templates/csharp/tests/quaternary/ParametersShould.cs": 0o644, - "templates/csharp/tests/quaternary/RequestBodiesShould.cs": 0o644, - "templates/csharp/tests/quaternary/ServersShould.cs": 0o644, - "templates/csharp/tests/quaternary/UnionsShould.cs": 0o644, - "templates/csharp/tests/secondary": 0o20000000755, - "templates/csharp/tests/secondary/AuthShould.cs": 0o644, - "templates/csharp/tests/secondary/HeadersShould.cs": 0o644, - "templates/csharp/tests/secondary/PaginationShould.cs": 0o644, - "templates/csharp/tests/secondary/ParametersShould.cs": 0o644, - "templates/csharp/tests/secondary/ResponseBodiesShould.cs": 0o644, - "templates/csharp/tests/secondary/ServersShould.cs": 0o644, - "templates/csharp/tests/security-options": 0o20000000755, - "templates/csharp/tests/security-options/AuthAdditionalShould.cs": 0o644, - "templates/csharp/tests/tertiary": 0o20000000755, - "templates/csharp/tests/tertiary/AuthShould.cs": 0o644, - "templates/csharp/tests/tertiary/CustomClientShould.cs": 0o644, - "templates/csharp/tests/tertiary/EventStreamAdditionalShould.cs": 0o644, - "templates/csharp/tests/tertiary/Helpers.cs": 0o644, - "templates/csharp/tests/tertiary/ParametersShould.cs": 0o644, - "templates/csharp/tests/tertiary/SchemaValidationShould.cs": 0o644, - "templates/csharp/tests/tertiary/ServersShould.cs": 0o644, - "templates/csharp/testusage": 0o20000000755, - "templates/csharp/testusage/testusage.csproj.stmpl": 0o644, - "templates/csharp/tsconfig.json": 0o644, - "templates/csharp/union.cs.stmpl": 0o644, - "templates/csharp/usage": 0o20000000755, - "templates/csharp/usage/block.stmpl": 0o644, - "templates/csharp/usage/call.stmpl": 0o644, - "templates/csharp/usage/errors.stmpl": 0o644, - "templates/csharp/usage/eventstream.stmpl": 0o644, - "templates/csharp/usage/init.stmpl": 0o644, - "templates/csharp/usage/model_snippet.stmpl": 0o644, - "templates/csharp/usage/pagination.stmpl": 0o644, - "templates/csharp/usage/result.stmpl": 0o644, - "templates/csharp/usage/retries.stmpl": 0o644, - "templates/csharp/usage/snippet.stmpl": 0o644, - "templates/csharp/usage/test.stmpl": 0o644, - "templates/go": 0o20000000755, - "templates/go/.gitignore.stmpl": 0o644, - "templates/go/auxiliary": 0o20000000755, - "templates/go/auxiliary/go.mod.stmpl": 0o644, - "templates/go/auxiliary/tests": 0o20000000755, - "templates/go/auxiliary/tests/files.go.stmpl": 0o644, - "templates/go/auxiliary/tests/helpers.go.stmpl": 0o644, - "templates/go/auxiliary/tests/testclient.go.stmpl": 0o644, - "templates/go/auxiliary/{{getErrorsLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getErrorsLocation}}/responsevalidationerror.go.stmpl": 0o644, - "templates/go/auxiliary/{{getErrorsLocation}}/{{getDefaultErrorFileName}}.go.stmpl": 0o644, - "templates/go/auxiliary/{{getNullableLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable.go.stmpl": 0o644, - "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable_test.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getPollingLocation}}/config.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}/errors.go.stmpl": 0o644, - "templates/go/auxiliary/{{getPollingLocation}}/option.go.stmpl": 0o644, - "templates/go/auxiliary/{{getRetryLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getRetryLocation}}/config.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/bigint.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/date.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/datetime.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/decimal.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/jsonl": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/jsonl/jsonl.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/pointers.go.stmpl": 0o644, - "templates/go/auxiliary/{{getTypesLocation}}/stream": 0o20000000755, - "templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}": 0o20000000755, - "templates/go/auxiliary/{{getUtilsLocation}}/contenttype.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/env.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/form.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/headers.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/jq.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/json.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_credentials.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_sessions.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/pathparams.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/queryparams.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/requestbody.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/retries.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/security.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/union.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/union_test.go.stmpl": 0o644, - "templates/go/auxiliary/{{getUtilsLocation}}/utils.go.stmpl": 0o644, - "templates/go/config.ts": 0o644, - "templates/go/devcontainers": 0o20000000755, - "templates/go/devcontainers/config.stmpl": 0o644, - "templates/go/devcontainers/setup.stmpl": 0o644, - "templates/go/docs": 0o20000000755, - "templates/go/docs/types": 0o20000000755, - "templates/go/docs/types/date.stmpl": 0o644, - "templates/go/enum.go.stmpl": 0o644, - "templates/go/error.go.stmpl": 0o644, - "templates/go/examples.ts": 0o644, - "templates/go/exclusions.ts": 0o644, - "templates/go/features.ts": 0o644, - "templates/go/hooks": 0o20000000755, - "templates/go/hooks/clientcredentials.go.stmpl": 0o644, - "templates/go/hooks/hooks.go.stmpl": 0o644, - "templates/go/hooks/hooks.ts": 0o644, - "templates/go/hooks/oauth2_password.go.stmpl": 0o644, - "templates/go/hooks/oauth2scopes.go.stmpl": 0o644, - "templates/go/hooks/registration.go.stmpl": 0o644, - "templates/go/includes": 0o20000000755, - "templates/go/includes/dependencies.ts": 0o644, - "templates/go/includes/documentation.ts": 0o644, - "templates/go/includes/enums.ts": 0o644, - "templates/go/includes/errors.ts": 0o644, - "templates/go/includes/eventstreaming.ts": 0o644, - "templates/go/includes/imports.ts": 0o644, - "templates/go/includes/includes.ts": 0o644, - "templates/go/includes/modelusage.ts": 0o644, - "templates/go/includes/open-unions.ts": 0o644, - "templates/go/includes/operations.ts": 0o644, - "templates/go/includes/options.ts": 0o644, - "templates/go/includes/polling.ts": 0o644, - "templates/go/includes/responses.ts": 0o644, - "templates/go/includes/retractions.ts": 0o644, - "templates/go/includes/sanitization.ts": 0o644, - "templates/go/includes/serverusage.ts": 0o644, - "templates/go/includes/templating.ts": 0o644, - "templates/go/includes/tests.ts": 0o644, - "templates/go/includes/transform.ts": 0o644, - "templates/go/includes/unions.ts": 0o644, - "templates/go/includes/utils.ts": 0o644, - "templates/go/main.ts": 0o644, - "templates/go/method.go.stmpl": 0o644, - "templates/go/method_options.go.stmpl": 0o644, - "templates/go/method_options_setup.go.stmpl": 0o644, - "templates/go/mockserver.ts": 0o644, - "templates/go/model.go.stmpl": 0o644, - "templates/go/modelfile.go.stmpl": 0o644, - "templates/go/options.go.stmpl": 0o644, - "templates/go/pagination.go.stmpl": 0o644, - "templates/go/readme": 0o20000000755, - "templates/go/readme/errors.stmpl": 0o644, - "templates/go/readme/eventstream.stmpl": 0o644, - "templates/go/readme/http_client.stmpl": 0o644, - "templates/go/readme/installation.stmpl": 0o644, - "templates/go/readme/jsonl.stmpl": 0o644, - "templates/go/readme/options.stmpl": 0o644, - "templates/go/readme/pagination.stmpl": 0o644, - "templates/go/readme/retries.stmpl": 0o644, - "templates/go/readme/security.stmpl": 0o644, - "templates/go/readme/types.stmpl": 0o644, - "templates/go/sdk.go.stmpl": 0o644, - "templates/go/sdkconfiguration.go.stmpl": 0o644, - "templates/go/standalone.ts": 0o644, - "templates/go/subsdk.go.stmpl": 0o644, - "templates/go/test.go.stmpl": 0o644, - "templates/go/testfile.go.stmpl": 0o644, - "templates/go/testhelper.go.stmpl": 0o644, - "templates/go/testhelpers.go.stmpl": 0o644, - "templates/go/testproject": 0o20000000755, - "templates/go/testproject/go.mod.stmpl": 0o644, - "templates/go/tests": 0o20000000755, - "templates/go/tests/basic-http": 0o20000000755, - "templates/go/tests/basic-http/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/client-credentials": 0o20000000755, - "templates/go/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, - "templates/go/tests/common": 0o20000000755, - "templates/go/tests/common/common_helpers_test.go": 0o644, - "templates/go/tests/custom-http": 0o20000000755, - "templates/go/tests/custom-http/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/oauth2-password": 0o20000000755, - "templates/go/tests/oauth2-password/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary": 0o20000000755, - "templates/go/tests/primary/allow_reserved_query_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/collections_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/custom_code_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/custom_omitempty_vs_builtin_test.go.stmpl": 0o644, - "templates/go/tests/primary/customclient_test.go.stmpl": 0o644, - "templates/go/tests/primary/enums_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/errors_test.go.stmpl": 0o644, - "templates/go/tests/primary/eventstreaming_test.go.stmpl": 0o644, - "templates/go/tests/primary/flattening_test.go.stmpl": 0o644, - "templates/go/tests/primary/globals_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/headers_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/helpers_test.go.stmpl": 0o644, - "templates/go/tests/primary/hooks_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/jsonlstreaming_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/open_unions_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/optional_field_serialization_test.go.stmpl": 0o644, - "templates/go/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/pagination_test.go.stmpl": 0o644, - "templates/go/tests/primary/parameter_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/polling_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/retries_test.go.stmpl": 0o644, - "templates/go/tests/primary/security_additional_test.go.stmpl": 0o644, - "templates/go/tests/primary/servers_test.go.stmpl": 0o644, - "templates/go/tests/primary/telemetry_test.go.stmpl": 0o644, - "templates/go/tests/primary/testdata": 0o20000000755, - "templates/go/tests/primary/testdata/testUpload.json": 0o644, - "templates/go/tests/primary/unions_additional_test.go.stmpl": 0o644, - "templates/go/tests/quaternary": 0o20000000755, - "templates/go/tests/quaternary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/quaternary/servers_test.go.stmpl": 0o644, - "templates/go/tests/quaternary/transform_test.go.stmpl": 0o644, - "templates/go/tests/secondary": 0o20000000755, - "templates/go/tests/secondary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/headers_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/json_omitempty_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/jsonlstreaming_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/pagination_test.go.stmpl": 0o644, - "templates/go/tests/secondary/redirects_followed_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/responsebodies_additional_test.go.stmpl": 0o644, - "templates/go/tests/secondary/servers_test.go.stmpl": 0o644, - "templates/go/tests/security-options": 0o20000000755, - "templates/go/tests/security-options/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary": 0o20000000755, - "templates/go/tests/tertiary/auth_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/servers_test.go.stmpl": 0o644, - "templates/go/tests/tertiary/unions_generic_additional_test.go.stmpl": 0o644, - "templates/go/testusage": 0o20000000755, - "templates/go/testusage/go.mod.stmpl": 0o644, - "templates/go/tsconfig.json": 0o644, - "templates/go/union.go.stmpl": 0o644, - "templates/go/usage": 0o20000000755, - "templates/go/usage/block.stmpl": 0o644, - "templates/go/usage/call.stmpl": 0o644, - "templates/go/usage/eventstream.stmpl": 0o644, - "templates/go/usage/init.stmpl": 0o644, - "templates/go/usage/jsonl.stmpl": 0o644, - "templates/go/usage/model_snippet.stmpl": 0o644, - "templates/go/usage/pagination.stmpl": 0o644, - "templates/go/usage/result.stmpl": 0o644, - "templates/go/usage/retries.stmpl": 0o644, - "templates/go/usage/snippet.stmpl": 0o644, - "templates/go/usage/test.stmpl": 0o644, - "templates/java": 0o20000000755, - "templates/java/SUNSET": 0o644, - "templates/java/config.ts": 0o644, - "templates/javav2": 0o20000000755, - "templates/javav2/.gitignore.stmpl": 0o644, - "templates/javav2/ClientCredentialsHook.java.stmpl": 0o644, - "templates/javav2/HIDDEN": 0o644, - "templates/javav2/OAuth2PasswordHook.java.stmpl": 0o644, - "templates/javav2/OAuth2Scopes.java.stmpl": 0o644, - "templates/javav2/SDKHooks.java.stmpl": 0o644, - "templates/javav2/async-method.java.stmpl": 0o644, - "templates/javav2/async-sdk.java.stmpl": 0o644, - "templates/javav2/async-subsdk.java.stmpl": 0o644, - "templates/javav2/auxiliary": 0o20000000755, - "templates/javav2/auxiliary/build.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/docs": 0o20000000755, - "templates/javav2/auxiliary/docs/utils": 0o20000000755, - "templates/javav2/auxiliary/docs/utils/Blob.md.stmpl": 0o644, - "templates/javav2/auxiliary/gradle": 0o20000000755, - "templates/javav2/auxiliary/gradle.properties.stmpl": 0o644, - "templates/javav2/auxiliary/gradle/wrapper": 0o20000000755, - "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.jar": 0o644, - "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.properties": 0o644, - "templates/javav2/auxiliary/gradlew": 0o755, - "templates/javav2/auxiliary/gradlew.bat": 0o755, - "templates/javav2/auxiliary/publishing.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/settings.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/spring": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/build.gradle.stmpl": 0o644, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main": 0o20000000755, - "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java": 0o20000000755, + "templates/csharp/config.ts": 0o644, + "templates/csharp/enum-extension.cs.stmpl": 0o644, + "templates/csharp/enum-open.cs.stmpl": 0o644, + "templates/csharp/enum.cs.stmpl": 0o644, + "templates/csharp/error.cs.stmpl": 0o644, + "templates/csharp/examples.ts": 0o644, + "templates/csharp/exclusions.ts": 0o644, + "templates/csharp/features.ts": 0o644, + "templates/csharp/hooks": 0o20000000755, + "templates/csharp/hooks/clientcredentials.cs.stmpl": 0o644, + "templates/csharp/hooks/hooks.cs.stmpl": 0o644, + "templates/csharp/hooks/hooks.ts": 0o644, + "templates/csharp/hooks/oauth2scopes.cs.stmpl": 0o644, + "templates/csharp/hooks/registration.cs.stmpl": 0o644, + "templates/csharp/hooks/types.cs.stmpl": 0o644, + "templates/csharp/includes": 0o20000000755, + "templates/csharp/includes/annotations.ts": 0o644, + "templates/csharp/includes/auxiliary.ts": 0o644, + "templates/csharp/includes/compat.ts": 0o644, + "templates/csharp/includes/dependencies.ts": 0o644, + "templates/csharp/includes/documentation.ts": 0o644, + "templates/csharp/includes/enums.ts": 0o644, + "templates/csharp/includes/errors.ts": 0o644, + "templates/csharp/includes/imports.ts": 0o644, + "templates/csharp/includes/includes.ts": 0o644, + "templates/csharp/includes/models.ts": 0o644, + "templates/csharp/includes/modelusage.ts": 0o644, + "templates/csharp/includes/namespaces.ts": 0o644, + "templates/csharp/includes/nuget.ts": 0o644, + "templates/csharp/includes/open-unions.ts": 0o644, + "templates/csharp/includes/pagination.ts": 0o644, + "templates/csharp/includes/responses.ts": 0o644, + "templates/csharp/includes/sanitization.ts": 0o644, + "templates/csharp/includes/security.ts": 0o644, + "templates/csharp/includes/serverusage.ts": 0o644, + "templates/csharp/includes/templating.ts": 0o644, + "templates/csharp/includes/tests.ts": 0o644, + "templates/csharp/includes/unimplemented.ts": 0o644, + "templates/csharp/includes/utils.ts": 0o644, + "templates/csharp/main.ts": 0o644, + "templates/csharp/method.cs.stmpl": 0o644, + "templates/csharp/mockserver.ts": 0o644, + "templates/csharp/model.cs.stmpl": 0o644, + "templates/csharp/modelfile.cs.stmpl": 0o644, + "templates/csharp/pagination.cs.stmpl": 0o644, + "templates/csharp/readme": 0o20000000755, + "templates/csharp/readme/errors.stmpl": 0o644, + "templates/csharp/readme/eventstream.stmpl": 0o644, + "templates/csharp/readme/http_client.stmpl": 0o644, + "templates/csharp/readme/installation.stmpl": 0o644, + "templates/csharp/readme/nuget.stmpl": 0o644, + "templates/csharp/readme/pagination.stmpl": 0o644, + "templates/csharp/readme/retries.stmpl": 0o644, + "templates/csharp/readme/security.stmpl": 0o644, + "templates/csharp/sdk.cs.stmpl": 0o644, + "templates/csharp/sdkconfig.cs.stmpl": 0o644, + "templates/csharp/servers.cs.stmpl": 0o644, + "templates/csharp/standalone.ts": 0o644, + "templates/csharp/subsdk.cs.stmpl": 0o644, + "templates/csharp/test.cs.stmpl": 0o644, + "templates/csharp/testfile.cs.stmpl": 0o644, + "templates/csharp/testhelper.cs.stmpl": 0o644, + "templates/csharp/testhelpers.cs.stmpl": 0o644, + "templates/csharp/testproject": 0o20000000755, + "templates/csharp/testproject/Program.cs.stmpl": 0o644, + "templates/csharp/testproject/testproject.csproj.stmpl": 0o644, + "templates/csharp/tests": 0o20000000755, + "templates/csharp/tests/Common": 0o20000000755, + "templates/csharp/tests/Common/CommonHelpers.cs.stmpl": 0o644, + "templates/csharp/tests/Common/Tests.csproj.stmpl": 0o644, + "templates/csharp/tests/basic-http": 0o20000000755, + "templates/csharp/tests/basic-http/AuthAdditionalTests.cs": 0o644, + "templates/csharp/tests/client-credentials": 0o20000000755, + "templates/csharp/tests/client-credentials-basic": 0o20000000755, + "templates/csharp/tests/client-credentials-basic/ClientCredentialsBasicHookShould.cs": 0o644, + "templates/csharp/tests/client-credentials/ClientCredentialsHookShould.cs": 0o644, + "templates/csharp/tests/custom-http": 0o20000000755, + "templates/csharp/tests/custom-http/AuthShould.cs": 0o644, + "templates/csharp/tests/primary": 0o20000000755, + "templates/csharp/tests/primary/AuthShould.cs": 0o644, + "templates/csharp/tests/primary/CancellationTokenShould.cs": 0o644, + "templates/csharp/tests/primary/CollectionsShould.cs": 0o644, + "templates/csharp/tests/primary/CustomClientShould.cs": 0o644, + "templates/csharp/tests/primary/ErrorsShould.cs": 0o644, + "templates/csharp/tests/primary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/FlatteningShould.cs": 0o644, + "templates/csharp/tests/primary/GlobalsAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/HeadersShould.cs": 0o644, + "templates/csharp/tests/primary/Helpers.cs": 0o644, + "templates/csharp/tests/primary/HooksShould.cs.stmpl": 0o644, + "templates/csharp/tests/primary/MultiLevelShould.cs": 0o644, + "templates/csharp/tests/primary/OpenEnumsShould.cs": 0o644, + "templates/csharp/tests/primary/OpenUnionsShould.cs": 0o644, + "templates/csharp/tests/primary/OptionalNullableFieldsShould.cs": 0o644, + "templates/csharp/tests/primary/PaginationShould.cs": 0o644, + "templates/csharp/tests/primary/ParametersShould.cs": 0o644, + "templates/csharp/tests/primary/RequestBodiesShould.cs": 0o644, + "templates/csharp/tests/primary/ResponseBodiesShould.cs": 0o644, + "templates/csharp/tests/primary/RetriesShould.cs": 0o644, + "templates/csharp/tests/primary/ServersShould.cs": 0o644, + "templates/csharp/tests/primary/SseStreamParserAdditionalShould.cs": 0o644, + "templates/csharp/tests/primary/TelemetryShould.cs.stmpl": 0o644, + "templates/csharp/tests/primary/UnionsShould.cs": 0o644, + "templates/csharp/tests/primary/testUpload.json": 0o644, + "templates/csharp/tests/quaternary": 0o20000000755, + "templates/csharp/tests/quaternary/AuthShould.cs": 0o644, + "templates/csharp/tests/quaternary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/quaternary/Helpers.cs": 0o644, + "templates/csharp/tests/quaternary/OpenEnumsShould.cs": 0o644, + "templates/csharp/tests/quaternary/OptionalNullableFieldsShould.cs": 0o644, + "templates/csharp/tests/quaternary/ParametersShould.cs": 0o644, + "templates/csharp/tests/quaternary/RequestBodiesShould.cs": 0o644, + "templates/csharp/tests/quaternary/ServersShould.cs": 0o644, + "templates/csharp/tests/quaternary/UnionsShould.cs": 0o644, + "templates/csharp/tests/secondary": 0o20000000755, + "templates/csharp/tests/secondary/AuthShould.cs": 0o644, + "templates/csharp/tests/secondary/HeadersShould.cs": 0o644, + "templates/csharp/tests/secondary/PaginationShould.cs": 0o644, + "templates/csharp/tests/secondary/ParametersShould.cs": 0o644, + "templates/csharp/tests/secondary/ResponseBodiesShould.cs": 0o644, + "templates/csharp/tests/secondary/ServersShould.cs": 0o644, + "templates/csharp/tests/security-options": 0o20000000755, + "templates/csharp/tests/security-options/AuthAdditionalShould.cs": 0o644, + "templates/csharp/tests/tertiary": 0o20000000755, + "templates/csharp/tests/tertiary/AuthShould.cs": 0o644, + "templates/csharp/tests/tertiary/CustomClientShould.cs": 0o644, + "templates/csharp/tests/tertiary/EventStreamAdditionalShould.cs": 0o644, + "templates/csharp/tests/tertiary/Helpers.cs": 0o644, + "templates/csharp/tests/tertiary/ParametersShould.cs": 0o644, + "templates/csharp/tests/tertiary/SchemaValidationShould.cs": 0o644, + "templates/csharp/tests/tertiary/ServersShould.cs": 0o644, + "templates/csharp/testusage": 0o20000000755, + "templates/csharp/testusage/testusage.csproj.stmpl": 0o644, + "templates/csharp/tsconfig.json": 0o644, + "templates/csharp/union.cs.stmpl": 0o644, + "templates/csharp/usage": 0o20000000755, + "templates/csharp/usage/block.stmpl": 0o644, + "templates/csharp/usage/call.stmpl": 0o644, + "templates/csharp/usage/errors.stmpl": 0o644, + "templates/csharp/usage/eventstream.stmpl": 0o644, + "templates/csharp/usage/init.stmpl": 0o644, + "templates/csharp/usage/model_snippet.stmpl": 0o644, + "templates/csharp/usage/pagination.stmpl": 0o644, + "templates/csharp/usage/result.stmpl": 0o644, + "templates/csharp/usage/retries.stmpl": 0o644, + "templates/csharp/usage/snippet.stmpl": 0o644, + "templates/csharp/usage/test.stmpl": 0o644, + "templates/go": 0o20000000755, + "templates/go/.gitignore.stmpl": 0o644, + "templates/go/auxiliary": 0o20000000755, + "templates/go/auxiliary/go.mod.stmpl": 0o644, + "templates/go/auxiliary/tests": 0o20000000755, + "templates/go/auxiliary/tests/files.go.stmpl": 0o644, + "templates/go/auxiliary/tests/helpers.go.stmpl": 0o644, + "templates/go/auxiliary/tests/testclient.go.stmpl": 0o644, + "templates/go/auxiliary/{{getErrorsLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getErrorsLocation}}/responsevalidationerror.go.stmpl": 0o644, + "templates/go/auxiliary/{{getErrorsLocation}}/{{getDefaultErrorFileName}}.go.stmpl": 0o644, + "templates/go/auxiliary/{{getNullableLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable.go.stmpl": 0o644, + "templates/go/auxiliary/{{getNullableLocation}}/optionalnullable_test.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getPollingLocation}}/config.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}/errors.go.stmpl": 0o644, + "templates/go/auxiliary/{{getPollingLocation}}/option.go.stmpl": 0o644, + "templates/go/auxiliary/{{getRetryLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getRetryLocation}}/config.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/bigint.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/date.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/datetime.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/decimal.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/jsonl": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/jsonl/jsonl.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/pointers.go.stmpl": 0o644, + "templates/go/auxiliary/{{getTypesLocation}}/stream": 0o20000000755, + "templates/go/auxiliary/{{getTypesLocation}}/stream/stream.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}": 0o20000000755, + "templates/go/auxiliary/{{getUtilsLocation}}/contenttype.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/env.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/form.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/headers.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/jq.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/json.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_credentials.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/oauth2_sessions.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/pathparams.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/queryparams.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/requestbody.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/retries.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/security.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/union.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/union_test.go.stmpl": 0o644, + "templates/go/auxiliary/{{getUtilsLocation}}/utils.go.stmpl": 0o644, + "templates/go/config.ts": 0o644, + "templates/go/devcontainers": 0o20000000755, + "templates/go/devcontainers/config.stmpl": 0o644, + "templates/go/devcontainers/setup.stmpl": 0o644, + "templates/go/docs": 0o20000000755, + "templates/go/docs/types": 0o20000000755, + "templates/go/docs/types/date.stmpl": 0o644, + "templates/go/enum.go.stmpl": 0o644, + "templates/go/error.go.stmpl": 0o644, + "templates/go/examples.ts": 0o644, + "templates/go/exclusions.ts": 0o644, + "templates/go/features.ts": 0o644, + "templates/go/hooks": 0o20000000755, + "templates/go/hooks/clientcredentials.go.stmpl": 0o644, + "templates/go/hooks/hooks.go.stmpl": 0o644, + "templates/go/hooks/hooks.ts": 0o644, + "templates/go/hooks/oauth2_password.go.stmpl": 0o644, + "templates/go/hooks/oauth2scopes.go.stmpl": 0o644, + "templates/go/hooks/registration.go.stmpl": 0o644, + "templates/go/includes": 0o20000000755, + "templates/go/includes/dependencies.ts": 0o644, + "templates/go/includes/documentation.ts": 0o644, + "templates/go/includes/enums.ts": 0o644, + "templates/go/includes/errors.ts": 0o644, + "templates/go/includes/eventstreaming.ts": 0o644, + "templates/go/includes/imports.ts": 0o644, + "templates/go/includes/includes.ts": 0o644, + "templates/go/includes/modelusage.ts": 0o644, + "templates/go/includes/open-unions.ts": 0o644, + "templates/go/includes/operations.ts": 0o644, + "templates/go/includes/options.ts": 0o644, + "templates/go/includes/polling.ts": 0o644, + "templates/go/includes/responses.ts": 0o644, + "templates/go/includes/retractions.ts": 0o644, + "templates/go/includes/sanitization.ts": 0o644, + "templates/go/includes/serverusage.ts": 0o644, + "templates/go/includes/templating.ts": 0o644, + "templates/go/includes/tests.ts": 0o644, + "templates/go/includes/transform.ts": 0o644, + "templates/go/includes/unions.ts": 0o644, + "templates/go/includes/utils.ts": 0o644, + "templates/go/main.ts": 0o644, + "templates/go/method.go.stmpl": 0o644, + "templates/go/method_options.go.stmpl": 0o644, + "templates/go/method_options_setup.go.stmpl": 0o644, + "templates/go/mockserver.ts": 0o644, + "templates/go/model.go.stmpl": 0o644, + "templates/go/modelfile.go.stmpl": 0o644, + "templates/go/options.go.stmpl": 0o644, + "templates/go/pagination.go.stmpl": 0o644, + "templates/go/readme": 0o20000000755, + "templates/go/readme/errors.stmpl": 0o644, + "templates/go/readme/eventstream.stmpl": 0o644, + "templates/go/readme/http_client.stmpl": 0o644, + "templates/go/readme/installation.stmpl": 0o644, + "templates/go/readme/jsonl.stmpl": 0o644, + "templates/go/readme/options.stmpl": 0o644, + "templates/go/readme/pagination.stmpl": 0o644, + "templates/go/readme/retries.stmpl": 0o644, + "templates/go/readme/security.stmpl": 0o644, + "templates/go/readme/types.stmpl": 0o644, + "templates/go/sdk.go.stmpl": 0o644, + "templates/go/sdkconfiguration.go.stmpl": 0o644, + "templates/go/standalone.ts": 0o644, + "templates/go/subsdk.go.stmpl": 0o644, + "templates/go/test.go.stmpl": 0o644, + "templates/go/testfile.go.stmpl": 0o644, + "templates/go/testhelper.go.stmpl": 0o644, + "templates/go/testhelpers.go.stmpl": 0o644, + "templates/go/testproject": 0o20000000755, + "templates/go/testproject/go.mod.stmpl": 0o644, + "templates/go/tests": 0o20000000755, + "templates/go/tests/basic-http": 0o20000000755, + "templates/go/tests/basic-http/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/client-credentials": 0o20000000755, + "templates/go/tests/client-credentials/client_credentials_hook_test.go.stmpl": 0o644, + "templates/go/tests/common": 0o20000000755, + "templates/go/tests/common/common_helpers_test.go": 0o644, + "templates/go/tests/custom-http": 0o20000000755, + "templates/go/tests/custom-http/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/oauth2-password": 0o20000000755, + "templates/go/tests/oauth2-password/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary": 0o20000000755, + "templates/go/tests/primary/allow_reserved_query_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/collections_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/custom_code_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/custom_omitempty_vs_builtin_test.go.stmpl": 0o644, + "templates/go/tests/primary/customclient_test.go.stmpl": 0o644, + "templates/go/tests/primary/enums_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/errors_test.go.stmpl": 0o644, + "templates/go/tests/primary/eventstreaming_test.go.stmpl": 0o644, + "templates/go/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/go/tests/primary/globals_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/headers_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/helpers_test.go.stmpl": 0o644, + "templates/go/tests/primary/hooks_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/jsonlstreaming_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/open_unions_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/optional_field_serialization_test.go.stmpl": 0o644, + "templates/go/tests/primary/optionalnullable_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/pagination_test.go.stmpl": 0o644, + "templates/go/tests/primary/parameter_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/parameter_encoding_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/polling_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/requestbodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/retries_test.go.stmpl": 0o644, + "templates/go/tests/primary/security_additional_test.go.stmpl": 0o644, + "templates/go/tests/primary/servers_test.go.stmpl": 0o644, + "templates/go/tests/primary/telemetry_test.go.stmpl": 0o644, + "templates/go/tests/primary/testdata": 0o20000000755, + "templates/go/tests/primary/testdata/testUpload.json": 0o644, + "templates/go/tests/primary/unions_additional_test.go.stmpl": 0o644, + "templates/go/tests/quaternary": 0o20000000755, + "templates/go/tests/quaternary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/quaternary/servers_test.go.stmpl": 0o644, + "templates/go/tests/quaternary/transform_test.go.stmpl": 0o644, + "templates/go/tests/secondary": 0o20000000755, + "templates/go/tests/secondary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/headers_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/json_omitempty_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/jsonlstreaming_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/pagination_test.go.stmpl": 0o644, + "templates/go/tests/secondary/redirects_followed_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/responsebodies_additional_test.go.stmpl": 0o644, + "templates/go/tests/secondary/servers_test.go.stmpl": 0o644, + "templates/go/tests/security-options": 0o20000000755, + "templates/go/tests/security-options/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary": 0o20000000755, + "templates/go/tests/tertiary/auth_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/parameter_encoding_legacy_additional_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/servers_test.go.stmpl": 0o644, + "templates/go/tests/tertiary/unions_generic_additional_test.go.stmpl": 0o644, + "templates/go/testusage": 0o20000000755, + "templates/go/testusage/go.mod.stmpl": 0o644, + "templates/go/tsconfig.json": 0o644, + "templates/go/union.go.stmpl": 0o644, + "templates/go/usage": 0o20000000755, + "templates/go/usage/block.stmpl": 0o644, + "templates/go/usage/call.stmpl": 0o644, + "templates/go/usage/eventstream.stmpl": 0o644, + "templates/go/usage/init.stmpl": 0o644, + "templates/go/usage/jsonl.stmpl": 0o644, + "templates/go/usage/model_snippet.stmpl": 0o644, + "templates/go/usage/pagination.stmpl": 0o644, + "templates/go/usage/result.stmpl": 0o644, + "templates/go/usage/retries.stmpl": 0o644, + "templates/go/usage/snippet.stmpl": 0o644, + "templates/go/usage/test.stmpl": 0o644, + "templates/java": 0o20000000755, + "templates/java/SUNSET": 0o644, + "templates/java/config.ts": 0o644, + "templates/javav2": 0o20000000755, + "templates/javav2/.gitignore.stmpl": 0o644, + "templates/javav2/ClientCredentialsHook.java.stmpl": 0o644, + "templates/javav2/HIDDEN": 0o644, + "templates/javav2/OAuth2PasswordHook.java.stmpl": 0o644, + "templates/javav2/OAuth2Scopes.java.stmpl": 0o644, + "templates/javav2/SDKHooks.java.stmpl": 0o644, + "templates/javav2/async-method.java.stmpl": 0o644, + "templates/javav2/async-sdk.java.stmpl": 0o644, + "templates/javav2/async-subsdk.java.stmpl": 0o644, + "templates/javav2/auxiliary": 0o20000000755, + "templates/javav2/auxiliary/build.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/docs": 0o20000000755, + "templates/javav2/auxiliary/docs/utils": 0o20000000755, + "templates/javav2/auxiliary/docs/utils/Blob.md.stmpl": 0o644, + "templates/javav2/auxiliary/gradle": 0o20000000755, + "templates/javav2/auxiliary/gradle.properties.stmpl": 0o644, + "templates/javav2/auxiliary/gradle/wrapper": 0o20000000755, + "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.jar": 0o644, + "templates/javav2/auxiliary/gradle/wrapper/gradle-wrapper.properties": 0o644, + "templates/javav2/auxiliary/gradlew": 0o755, + "templates/javav2/auxiliary/gradlew.bat": 0o755, + "templates/javav2/auxiliary/publishing.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/settings.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/spring": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/build.gradle.stmpl": 0o644, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main": 0o20000000755, + "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java": 0o20000000755, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}": 0o20000000755, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}/ConditionalOnPropertyPrefix.java.stmpl": 0o644, "templates/javav2/auxiliary/spring/{{templateProjectName .}}-spring-boot-autoconfigure/src/main/java/{{templateAuxPath .}}/OnPropertyPrefixCondition.java.stmpl": 0o644, diff --git a/templates/templates/cli/README.md b/templates/templates/cli/README.md index 7e26522b5..597555093 100644 --- a/templates/templates/cli/README.md +++ b/templates/templates/cli/README.md @@ -171,7 +171,7 @@ The generator follows a **generated commands + shared runtime** pattern: │ policy.go ─ Shared prompt/form/explorer policy │ │ interactive.go ─ Missing-flag prompting via forms │ │ forms/ │ -│ *.go ─ Input/select/confirm terminal forms │ +│ forms.go ─ Input/select/confirm terminal forms │ │ explorer/ │ │ *.go ─ Interactive command explorer TUI │ │ testclient/ │ diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index 7d7dfd132..b9e2a2f7a 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -4,10 +4,7 @@ {{- addImport "internal/flagutil" true -}} {{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- end}} {{- addImport "fmt" -}} -{{- addImport "os" -}} -{{- addImport "golang.org/x/term" -}} {{- addImport "internal/forms" true -}} -{{- addImport "charm.land/lipgloss/v2" -}} package cli {{genImports}} @@ -148,58 +145,3 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } - -// authFormTheme builds the form theme for auth login. -func authFormTheme() forms.Theme { - return forms.ThemeFunc(authFormStyles) -} - -func authFormStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - - return &t -} - -// authFormWidth returns the terminal width for sizing forms. -func authFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl deleted file mode 100644 index e774f8237..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/accessible.go.stmpl +++ /dev/null @@ -1,182 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "bufio" - "cmp" - "errors" - "fmt" - "io" - "slices" - "strconv" - "strings" - - "golang.org/x/term" -) - -func atoi(s string) (int, error) { - if strings.TrimSpace(s) == "" { - return -1, nil - } - return strconv.Atoi(s) -} - -// promptInt prompts a user for an integer between a certain range. -// -// Given invalid input (non-integers, integers outside of the range), the user -// will continue to be reprompted until a valid input is given, ensuring that -// the return value is always valid. -func promptInt( - out io.Writer, - in io.Reader, - prompt string, - low, high int, - defaultValue *int, -) int { - var choice int - - validInt := func(s string) error { - if strings.TrimSpace(s) == "" && defaultValue != nil { - return nil - } - i, err := atoi(s) - if err != nil || i < low || i > high { - if low == high { - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be %d", low) - } - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) - } - return nil - } - - input := promptString( - out, - in, - prompt, - ptrToStr(defaultValue, strconv.Itoa), - validInt, - ) - choice, _ = strconv.Atoi(input) - return choice -} - -func parseBool(s string) (bool, error) { - s = strings.ToLower(s) - - if slices.Contains([]string{"y", "yes"}, s) { - return true, nil - } - - // As a special case, we default to "" to no since the usage of this - // function suggests N is the default. - if slices.Contains([]string{"n", "no"}, s) { - return false, nil - } - - return false, errors.New("invalid input. please try again") -} - -// promptBool prompts a user for a boolean value. -// -// Given invalid input (non-boolean), the user will continue to be reprompted -// until a valid input is given, ensuring that the return value is always valid. -func promptBool( - out io.Writer, - in io.Reader, - prompt string, - defaultValue bool, -) bool { - validBool := func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - _, err := parseBool(s) - return err - } - - input := promptString( - out, in, prompt, - boolToStr(defaultValue), - validBool, - ) - b, _ := parseBool(input) - return b -} - -// promptPassword allows to prompt for a password. -// In must be the fd of a tty. -func promptPassword( - out io.Writer, - in uintptr, - prompt string, - validator func(input string) error, -) (string, error) { - for { - _, _ = fmt.Fprint(out, prompt) - pwd, err := term.ReadPassword(int(in)) - if err != nil { - return "", err - } - _, _ = fmt.Fprintln(out) - if err := validator(string(pwd)); err != nil { - _, _ = fmt.Fprintln(out, err) - continue - } - return string(pwd), nil - } -} - -// promptString prompts a user for a string value and validates it against a -// validator function. It re-prompts the user until a valid input is given. -func promptString( - out io.Writer, - in io.Reader, - prompt string, - defaultValue string, - validator func(input string) error, -) string { - scanner := bufio.NewScanner(in) - - var ( - valid bool - input string - ) - - for !valid { - _, _ = fmt.Fprint(out, prompt) - if !scanner.Scan() { - // no way to bubble up errors or signal cancellation - // but the program is probably not continuing if - // stdin sent EOF - _, _ = fmt.Fprintln(out) - break - } - input = scanner.Text() - - if err := validator(input); err != nil { - _, _ = fmt.Fprintln(out, err) - continue - } - - break - } - - return cmp.Or(strings.TrimSpace(input), defaultValue) -} - -func ptrToStr[T any](t *T, fn func(t T) string) string { - if t == nil { - return "" - } - return fn(*t) -} - -func boolToStr(b bool) string { - if b { - return "y" - } - return "N" -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl deleted file mode 100644 index 899b422e1..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/accessor.go.stmpl +++ /dev/null @@ -1,46 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -// Accessor give read/write access to field values. -type Accessor[T any] interface { - Get() T - Set(value T) -} - -// EmbeddedAccessor is a basic accessor, acting as the default one for fields. -type EmbeddedAccessor[T any] struct { - value T -} - -// Get gets the value. -func (a *EmbeddedAccessor[T]) Get() T { - return a.value -} - -// Set sets the value. -func (a *EmbeddedAccessor[T]) Set(value T) { - a.value = value -} - -// PointerAccessor allows field value to be exposed as a pointed variable. -type PointerAccessor[T any] struct { - value *T -} - -// NewPointerAccessor returns a new pointer accessor. -func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { - return &PointerAccessor[T]{ - value: value, - } -} - -// Get gets the value. -func (a *PointerAccessor[T]) Get() T { - return *a.value -} - -// Set sets the value. -func (a *PointerAccessor[T]) Set(value T) { - *a.value = value -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl deleted file mode 100644 index fbf4f06eb..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/doc.go.stmpl +++ /dev/null @@ -1,31 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -// MIT License -// -// Copyright (c) 2023–2026 Charm -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -// Package forms renders the terminal forms used by guided prompts: text -// inputs, multi-line text, selects, and confirmations, with an accessible -// line-prompt mode for screen readers and non-TTY input. -// -// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), -// reduced to the fields and form options this CLI uses. -package forms -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl deleted file mode 100644 index 5e3564903..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/field_confirm.go.stmpl +++ /dev/null @@ -1,308 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "cmp" - "io" - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Confirm is a form confirm field. -type Confirm struct { - accessor Accessor[bool] - - // customization - title string - description string - affirmative string - negative string - - // error handling - validate func(bool) error - err error - - // state - focused bool - - // options - width int - height int - inline bool - theme Theme - hasDarkBg bool - keymap ConfirmKeyMap - buttonAlignment lipgloss.Position -} - -// NewConfirm returns a new confirm field. -func NewConfirm() *Confirm { - return &Confirm{ - accessor: &EmbeddedAccessor[bool]{}, - affirmative: "Yes", - negative: "No", - validate: func(bool) error { return nil }, - buttonAlignment: lipgloss.Center, - } -} - -// Validate sets the validation function of the confirm field. -func (c *Confirm) Validate(validate func(bool) error) *Confirm { - c.validate = validate - return c -} - -// Error returns the error of the confirm field. -func (c *Confirm) Error() error { - return c.err -} - -// Skip returns whether the confirm should be skipped or should be blocking. -func (*Confirm) Skip() bool { - return false -} - -// Zoom returns whether the input should be zoomed. -func (*Confirm) Zoom() bool { - return false -} - -// Affirmative sets the affirmative value of the confirm field. -func (c *Confirm) Affirmative(affirmative string) *Confirm { - c.affirmative = affirmative - return c -} - -// Negative sets the negative value of the confirm field. -func (c *Confirm) Negative(negative string) *Confirm { - c.negative = negative - return c -} - -// Value sets the value of the confirm field. -func (c *Confirm) Value(value *bool) *Confirm { - return c.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the confirm field. -func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { - c.accessor = accessor - return c -} - -// Title sets the title of the confirm field. -func (c *Confirm) Title(title string) *Confirm { - c.title = title - return c -} - -// Description sets the description of the confirm field. -func (c *Confirm) Description(description string) *Confirm { - c.description = description - return c -} - -// Inline sets whether the field should be inline. -func (c *Confirm) Inline(inline bool) *Confirm { - c.inline = inline - return c -} - -// Focus focuses the confirm field. -func (c *Confirm) Focus() tea.Cmd { - c.focused = true - return nil -} - -// Blur blurs the confirm field. -func (c *Confirm) Blur() tea.Cmd { - c.focused = false - c.err = c.validate(c.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the confirm field. -func (c *Confirm) KeyBinds() []key.Binding { - return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} -} - -// Init initializes the confirm field. -func (c *Confirm) Init() tea.Cmd { - return nil -} - -// Update updates the confirm field. -func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - c.hasDarkBg = msg.IsDark() - case tea.KeyPressMsg: - c.err = nil - switch { - case key.Matches(msg, c.keymap.Toggle): - if c.negative == "" { - break - } - c.accessor.Set(!c.accessor.Get()) - case key.Matches(msg, c.keymap.Prev): - cmds = append(cmds, PrevField) - case key.Matches(msg, c.keymap.Next, c.keymap.Submit): - cmds = append(cmds, NextField) - case key.Matches(msg, c.keymap.Accept): - c.accessor.Set(true) - cmds = append(cmds, NextField) - case key.Matches(msg, c.keymap.Reject): - c.accessor.Set(false) - cmds = append(cmds, NextField) - } - } - - return c, tea.Batch(cmds...) -} - -func (c *Confirm) activeStyles() *FieldStyles { - theme := c.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if c.focused { - return &theme.Theme(c.hasDarkBg).Focused - } - return &theme.Theme(c.hasDarkBg).Blurred -} - -// View renders the confirm field. -func (c *Confirm) View() string { - styles := c.activeStyles() - maxWidth := c.width - styles.Base.GetHorizontalFrameSize() - - var wroteHeader bool - var sb strings.Builder - if c.title != "" { - sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) - wroteHeader = true - } - if c.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - wroteHeader = true - } - - if c.description != "" { - description := styles.Description.Render(wrap(c.description, maxWidth)) - if !c.inline && c.description != "" { - sb.WriteString("\n") - } - sb.WriteString(description) - wroteHeader = true - } - - if !c.inline && wroteHeader { - sb.WriteString("\n") - sb.WriteString("\n") - } - - var negative string - var affirmative string - if c.negative != "" { - if c.accessor.Get() { - affirmative = styles.FocusedButton.Render(c.affirmative) - negative = styles.BlurredButton.Render(c.negative) - } else { - affirmative = styles.BlurredButton.Render(c.affirmative) - negative = styles.FocusedButton.Render(c.negative) - } - c.keymap.Reject.SetHelp("n", c.negative) - } else { - affirmative = styles.FocusedButton.Render(c.affirmative) - c.keymap.Reject.SetEnabled(false) - } - - c.keymap.Accept.SetHelp("y", c.affirmative) - - buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) - - promptWidth := lipgloss.Width(sb.String()) - buttonsWidth := lipgloss.Width(buttonsRow) - - renderWidth := max(buttonsWidth, promptWidth) - - style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) - - sb.WriteString(style.Render(buttonsRow)) - return styles.Base.Width(c.width).Height(c.height). - Render(sb.String()) -} - -// RunAccessible runs the confirm field in accessible mode. -func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { - styles := c.activeStyles() - defaultValue := c.GetValue().(bool) - opts := "[y/N]" - if defaultValue { - opts = "[Y/n]" - } - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(c.title, "Choose"), opts) - c.accessor.Set(promptBool(w, r, prompt, defaultValue)) - return nil -} - -func (c *Confirm) String() string { - if c.accessor.Get() { - return c.affirmative - } - return c.negative -} - -// WithTheme sets the theme of the confirm field. -func (c *Confirm) WithTheme(theme Theme) Field { - if c.theme != nil { - return c - } - c.theme = theme - return c -} - -// WithKeyMap sets the keymap of the confirm field. -func (c *Confirm) WithKeyMap(k *KeyMap) Field { - c.keymap = k.Confirm - return c -} - -// WithWidth sets the width of the confirm field. -func (c *Confirm) WithWidth(width int) Field { - c.width = width - return c -} - -// WithHeight sets the height of the confirm field. -func (c *Confirm) WithHeight(height int) Field { - c.height = height - return c -} - -// WithPosition sets the position of the confirm field. -func (c *Confirm) WithPosition(p FieldPosition) Field { - c.keymap.Prev.SetEnabled(!p.IsFirst()) - c.keymap.Next.SetEnabled(!p.IsLast()) - c.keymap.Submit.SetEnabled(p.IsLast()) - return c -} - -// WithButtonAlignment sets the button position of the confirm field. -func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { - c.buttonAlignment = p - return c -} - -// GetValue returns the value of the field. -func (c *Confirm) GetValue() any { - return c.accessor.Get() -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl deleted file mode 100644 index 085f925e0..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/field_input.go.stmpl +++ /dev/null @@ -1,345 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "cmp" - "errors" - "fmt" - "io" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Input is a input field. -// -// The input field is a field that allows the user to enter text. Use it to user -// input. It can be used for collecting text, passwords, or other short input. -// -// The input field supports Suggestions, Placeholder, and Validation. -type Input struct { - accessor Accessor[string] - - title string - description string - - textinput textinput.Model - - inline bool - validate func(string) error - err error - focused bool - - width int - height int - - theme Theme - hasDarkBg bool - keymap InputKeyMap -} - -// NewInput creates a new input field. -// -// The input field is a field that allows the user to enter text. Use it to user -// input. It can be used for collecting text, passwords, or other short input. -// -// The input field supports Suggestions, Placeholder, and Validation. -func NewInput() *Input { - input := textinput.New() - - i := &Input{ - accessor: &EmbeddedAccessor[string]{}, - textinput: input, - validate: func(string) error { return nil }, - } - - return i -} - -// Value sets the value of the input field. -func (i *Input) Value(value *string) *Input { - return i.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the input field. -func (i *Input) Accessor(accessor Accessor[string]) *Input { - i.accessor = accessor - i.textinput.SetValue(i.accessor.Get()) - return i -} - -// Title sets the title of the input field. -func (i *Input) Title(title string) *Input { - i.title = title - return i -} - -// Description sets the description of the input field. -func (i *Input) Description(description string) *Input { - i.description = description - return i -} - -// Prompt sets the prompt of the input field. -func (i *Input) Prompt(prompt string) *Input { - i.textinput.Prompt = prompt - return i -} - -// CharLimit sets the character limit of the input field. -func (i *Input) CharLimit(charlimit int) *Input { - i.textinput.CharLimit = charlimit - return i -} - -// EchoMode sets the input behavior of the text Input field. -type EchoMode textinput.EchoMode - -const ( - // EchoModeNormal displays text as is. - // This is the default behavior. - EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal) - - // EchoModePassword displays the EchoCharacter mask instead of actual characters. - // This is commonly used for password fields. - EchoModePassword EchoMode = EchoMode(textinput.EchoPassword) - - // EchoModeNone displays nothing as characters are entered. - // This is commonly seen for password fields on the command line. - EchoModeNone EchoMode = EchoMode(textinput.EchoNone) -) - -// EchoMode sets the echo mode of the input. -func (i *Input) EchoMode(mode EchoMode) *Input { - i.textinput.EchoMode = textinput.EchoMode(mode) - return i -} - -// Placeholder sets the placeholder of the text input. -func (i *Input) Placeholder(str string) *Input { - i.textinput.Placeholder = str - return i -} - -// Inline sets whether the title and input should be on the same line. -func (i *Input) Inline(inline bool) *Input { - i.inline = inline - return i -} - -// Validate sets the validation function of the input field. -func (i *Input) Validate(validate func(string) error) *Input { - i.validate = validate - return i -} - -// Error returns the error of the input field. -func (i *Input) Error() error { return i.err } - -// Skip returns whether the input should be skipped or should be blocking. -func (*Input) Skip() bool { return false } - -// Zoom returns whether the input should be zoomed. -func (*Input) Zoom() bool { return false } - -// Focus focuses the input field. -func (i *Input) Focus() tea.Cmd { - i.focused = true - return i.textinput.Focus() -} - -// Blur blurs the input field. -func (i *Input) Blur() tea.Cmd { - i.focused = false - i.accessor.Set(i.textinput.Value()) - i.textinput.Blur() - i.err = i.validate(i.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the input field. -func (i *Input) KeyBinds() []key.Binding { - if i.textinput.ShowSuggestions { - return []key.Binding{i.keymap.AcceptSuggestion, i.keymap.Prev, i.keymap.Submit, i.keymap.Next} - } - return []key.Binding{i.keymap.Prev, i.keymap.Submit, i.keymap.Next} -} - -// Init initializes the input field. -func (i *Input) Init() tea.Cmd { - i.textinput.Blur() - return nil -} - -// Update updates the input field. -func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - i.hasDarkBg = msg.IsDark() - case updateFieldMsg: - return i, nil - case tea.KeyPressMsg: - i.err = nil - - switch { - case key.Matches(msg, i.keymap.Prev): - cmds = append(cmds, PrevField) - case key.Matches(msg, i.keymap.Next, i.keymap.Submit): - value := i.textinput.Value() - i.err = i.validate(value) - if i.err != nil { - return i, nil - } - cmds = append(cmds, NextField) - } - } - - i.textinput, cmd = i.textinput.Update(msg) - cmds = append(cmds, cmd) - i.accessor.Set(i.textinput.Value()) - - return i, tea.Batch(cmds...) -} - -func (i *Input) activeStyles() *FieldStyles { - theme := i.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if i.focused { - return &theme.Theme(i.hasDarkBg).Focused - } - return &theme.Theme(i.hasDarkBg).Blurred -} - -// View renders the input field. -func (i *Input) View() string { - styles := i.activeStyles() - maxWidth := i.width - styles.Base.GetHorizontalFrameSize() - - // NB: since the method is on a pointer receiver these are being mutated. - // Because this runs on every render this shouldn't matter in practice, - // however. - st := i.textinput.Styles() - st.Cursor.Color = styles.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.Text = styles.TextInput.Text - st.Focused.Placeholder = styles.TextInput.Placeholder - i.textinput.SetStyles(st) - - // Adjust text input size to its char limit if it fit in its width - if i.textinput.CharLimit > 0 { - i.textinput.SetWidth(max(min(i.textinput.CharLimit, i.textinput.Width(), maxWidth), 0)) - } - - var sb strings.Builder - if i.title != "" { - sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth))) - if !i.inline { - sb.WriteString("\n") - } - } - if i.description != "" { - sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth))) - if !i.inline { - sb.WriteString("\n") - } - } - sb.WriteString(i.textinput.View()) - - return styles.Base. - Width(i.width). - Height(i.height). - Render(sb.String()) -} - -// RunAccessible runs the input field in accessible mode. -func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { - styles := i.activeStyles() - validator := func(input string) error { - if i.textinput.CharLimit > 0 && len(input) > i.textinput.CharLimit { - return fmt.Errorf("Input cannot exceed %d characters", i.textinput.CharLimit) - } - return i.validate(input) - } - - switch i.textinput.EchoMode { - case textinput.EchoNormal: - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(i.title, "Input:")) - value := promptString(w, r, prompt, i.GetValue().(string), validator) - i.accessor.Set(value) - return nil - default: - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(i.title, "Password:")) - if fd, ok := r.(interface{ Fd() uintptr }); ok { - value, err := promptPassword(w, fd.Fd(), prompt, validator) - if err != nil { - return err - } - i.accessor.Set(value) - return nil - } - return errors.New("password asking needs a tty") - } -} - -// WithKeyMap sets the keymap on an input field. -func (i *Input) WithKeyMap(k *KeyMap) Field { - i.keymap = k.Input - i.textinput.KeyMap.AcceptSuggestion = i.keymap.AcceptSuggestion - return i -} - -// WithTheme sets the theme of the input field. -func (i *Input) WithTheme(theme Theme) Field { - if i.theme != nil { - return i - } - i.theme = theme - return i -} - -// WithWidth sets the width of the input field. -func (i *Input) WithWidth(width int) Field { - styles := i.activeStyles() - i.width = width - frameSize := styles.Base.GetHorizontalFrameSize() - promptWidth := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) - titleWidth := lipgloss.Width(styles.Title.Render(i.title)) - descriptionWidth := lipgloss.Width(styles.Description.Render(i.description)) - i.textinput.SetWidth(width - frameSize - promptWidth - 1) - if i.inline { - i.textinput.SetWidth(i.textinput.Width() - titleWidth - descriptionWidth) - } - return i -} - -// WithHeight sets the height of the input field. -func (i *Input) WithHeight(height int) Field { - i.height = height - return i -} - -// WithPosition sets the position of the input field. -func (i *Input) WithPosition(p FieldPosition) Field { - i.keymap.Prev.SetEnabled(!p.IsFirst()) - i.keymap.Next.SetEnabled(!p.IsLast()) - i.keymap.Submit.SetEnabled(p.IsLast()) - return i -} - -// GetValue returns the value of the field. -func (i *Input) GetValue() any { - return i.accessor.Get() -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl deleted file mode 100644 index 318b914e9..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/field_select.go.stmpl +++ /dev/null @@ -1,680 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "cmp" - "fmt" - "io" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -const ( - minHeight = 1 - defaultHeight = 10 -) - -// Select is a select field. -// -// A select field is a field that allows the user to select from a list of -// options. The options can be provided statically or dynamically using Options -// or OptionsFunc. The options can be filtered using "/" and navigation is done -// using j/k, up/down, or ctrl+n/ctrl+p keys. -type Select[T comparable] struct { - accessor Accessor[T] - - viewport viewport.Model - - title string - description string - options []Option[T] - filteredOptions []Option[T] - - validate func(T) error - err error - - selected int - focused bool - filtering bool - filter textinput.Model - - inline bool - width int - height int - theme Theme - hasDarkBg bool - keymap SelectKeyMap -} - -// NewSelect creates a new select field. -// -// A select field is a field that allows the user to select from a list of -// options. The options can be provided statically or dynamically using Options -// or OptionsFunc. The options can be filtered using "/" and navigation is done -// using j/k, up/down, or ctrl+n/ctrl+p keys. -func NewSelect[T comparable]() *Select[T] { - filter := textinput.New() - filter.Prompt = "/" - - return &Select[T]{ - accessor: &EmbeddedAccessor[T]{}, - validate: func(T) error { return nil }, - filtering: false, - filter: filter, - } -} - -// Value sets the value of the select field. -func (s *Select[T]) Value(value *T) *Select[T] { - return s.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the select field. -func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] { - s.accessor = accessor - s.selectValue(s.accessor.Get()) - s.updateValue() - return s -} - -func (s *Select[T]) selectValue(value T) { - for i, o := range s.options { - if o.Value == value { - s.selected = i - break - } - } -} - -// Title sets the title of the select field. -func (s *Select[T]) Title(title string) *Select[T] { - s.title = title - return s -} - -// Filtering sets the filtering state of the select field. -func (s *Select[T]) Filtering(filtering bool) *Select[T] { - s.filtering = filtering - s.filter.Focus() - return s -} - -// Description sets the description of the select field. -func (s *Select[T]) Description(description string) *Select[T] { - s.description = description - return s -} - -// Options sets the options of the select field. -// -// This is what your user will select from. -// -// Title -// Description -// -// -> Option 1 -// Option 2 -// Option 3 -func (s *Select[T]) Options(options ...Option[T]) *Select[T] { - if len(options) <= 0 { - return s - } - s.options = options - s.filteredOptions = options - - s.selectOption() - - s.updateViewportSize() - s.updateValue() - - return s -} - -func (s *Select[T]) selectOption() { - // Set the cursor to the existing value. - for i, option := range s.options { - if option.Value == s.accessor.Get() { - s.selected = i - break - } - } - s.ensureCursorVisible() -} - -// Inline sets whether the select input should be inline. -func (s *Select[T]) Inline(v bool) *Select[T] { - s.inline = v - if v { - s.Height(1) - } - s.keymap.Left.SetEnabled(v) - s.keymap.Right.SetEnabled(v) - s.keymap.Up.SetEnabled(!v) - s.keymap.Down.SetEnabled(!v) - return s -} - -// Height sets the height of the select field. If the number of options exceeds -// the height, the select field will become scrollable. -func (s *Select[T]) Height(height int) *Select[T] { - s.height = height - s.updateViewportSize() - return s -} - -// Validate sets the validation function of the select field. -func (s *Select[T]) Validate(validate func(T) error) *Select[T] { - s.validate = validate - return s -} - -// Error returns the error of the select field. -func (s *Select[T]) Error() error { return s.err } - -// Skip returns whether the select should be skipped or should be blocking. -func (*Select[T]) Skip() bool { return false } - -// Zoom returns whether the input should be zoomed. -func (*Select[T]) Zoom() bool { return false } - -// Focus focuses the select field. -func (s *Select[T]) Focus() tea.Cmd { - s.focused = true - return nil -} - -// Blur blurs the select field. -func (s *Select[T]) Blur() tea.Cmd { - value := s.accessor.Get() - if s.inline { - s.clearFilter() - s.selectValue(value) - } - s.focused = false - s.err = s.validate(value) - return nil -} - -// Hovered returns the value of the option under the cursor, and a bool -// indicating whether one was found. If there are no visible options, returns -// a zero-valued T and false. -func (s *Select[T]) Hovered() (T, bool) { - if len(s.filteredOptions) == 0 || s.selected >= len(s.filteredOptions) { - var zero T - return zero, false - } - return s.filteredOptions[s.selected].Value, true -} - -// KeyBinds returns the help keybindings for the select field. -func (s *Select[T]) KeyBinds() []key.Binding { - return []key.Binding{ - s.keymap.Up, - s.keymap.Down, - s.keymap.Left, - s.keymap.Right, - s.keymap.Filter, - s.keymap.SetFilter, - s.keymap.ClearFilter, - s.keymap.Prev, - s.keymap.Next, - s.keymap.Submit, - } -} - -// Init initializes the select field. -func (s *Select[T]) Init() tea.Cmd { - return nil -} - -// Update updates the select field. -func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) { - s.updateViewportSize() - - var cmd tea.Cmd - if s.filtering { - s.filter, cmd = s.filter.Update(msg) - } - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - s.hasDarkBg = msg.IsDark() - case updateFieldMsg: - return s, nil - - case tea.KeyPressMsg: - s.err = nil - switch { - case key.Matches(msg, s.keymap.Filter): - s.setFiltering(true) - return s, s.filter.Focus() - case key.Matches(msg, s.keymap.SetFilter): - if len(s.filteredOptions) <= 0 { - s.filter.SetValue("") - s.filteredOptions = s.options - } - s.setFiltering(false) - case key.Matches(msg, s.keymap.ClearFilter): - s.clearFilter() - case key.Matches(msg, s.keymap.Up, s.keymap.Left): - // When filtering we should ignore j/k keybindings - // - // XXX: Currently, the below check doesn't account for keymap - // changes. When making this fix it's worth considering ignoring - // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 - // may not be enough when international keyboards are considered. - if s.filtering && (msg.String() == "k" || msg.String() == "h") { - break - } - s.selected = s.selected - 1 - if s.selected < 0 { - s.selected = len(s.filteredOptions) - 1 - s.viewport.GotoBottom() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msg, s.keymap.GotoTop): - if s.filtering { - break - } - s.selected = 0 - s.viewport.GotoTop() - s.updateValue() - case key.Matches(msg, s.keymap.GotoBottom): - if s.filtering { - break - } - s.selected = len(s.filteredOptions) - 1 - s.viewport.GotoBottom() - case key.Matches(msg, s.keymap.HalfPageUp): - s.selected = max(s.selected-s.viewport.Height()/2, 0) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msg, s.keymap.HalfPageDown): - s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msg, s.keymap.Down, s.keymap.Right): - // When filtering we should ignore j/k keybindings - // - // XXX: See note in the previous case match. - if s.filtering && (msg.String() == "j" || msg.String() == "l") { - break - } - s.selected = s.selected + 1 - if s.selected > len(s.filteredOptions)-1 { - s.selected = 0 - s.viewport.GotoTop() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msg, s.keymap.Prev): - if s.selected >= len(s.filteredOptions) { - break - } - s.updateValue() - s.err = s.validate(s.accessor.Get()) - if s.err != nil { - return s, nil - } - s.updateValue() - return s, PrevField - case key.Matches(msg, s.keymap.Next, s.keymap.Submit): - if s.selected >= len(s.filteredOptions) { - break - } - s.setFiltering(false) - s.updateValue() - s.err = s.validate(s.accessor.Get()) - if s.err != nil { - return s, nil - } - s.updateValue() - return s, NextField - } - - if s.filtering { - s.filteredOptions = s.options - if s.filter.Value() != "" { - s.filteredOptions = nil - for _, option := range s.options { - if s.filterFunc(option.Key) { - s.filteredOptions = append(s.filteredOptions, option) - } - } - } - if len(s.filteredOptions) > 0 { - s.selected = min(s.selected, len(s.filteredOptions)-1) - } - } - - s.ensureCursorVisible() - } - - return s, cmd -} - -func (s *Select[T]) updateValue() { - if s.selected < len(s.filteredOptions) && s.selected >= 0 { - s.accessor.Set(s.filteredOptions[s.selected].Value) - } -} - -// updateViewportSize updates the viewport size according to the Height setting -// on this select field. -func (s *Select[T]) updateViewportSize() { - if s.height > 0 { - yoffset := 0 - if ss := s.titleView(); ss != "" { - yoffset += lipgloss.Height(ss) - } - if ss := s.descriptionView(); ss != "" { - yoffset += lipgloss.Height(ss) - } - s.viewport.SetHeight(max(minHeight, s.height-yoffset)) - s.ensureCursorVisible() - } else { - // If no height is set size the viewport to the number of options. - v, _, _ := s.optionsView() - s.viewport.SetHeight(lipgloss.Height(v)) - } - if s.width > 0 { - s.viewport.SetWidth(s.width) - } else { - v, _, _ := s.optionsView() - s.viewport.SetWidth(lipgloss.Width(v)) - } -} - -func (s *Select[T]) activeStyles() *FieldStyles { - theme := s.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if s.focused { - return &theme.Theme(s.hasDarkBg).Focused - } - return &theme.Theme(s.hasDarkBg).Blurred -} - -func (s *Select[T]) titleView() string { - var ( - styles = s.activeStyles() - sb = strings.Builder{} - maxWidth = s.width - styles.Base.GetHorizontalFrameSize() - ) - if s.filtering { - sb.WriteString(s.filter.View()) - } else if s.filter.Value() != "" && !s.inline { - sb.WriteString(styles.Description.Render("/" + s.filter.Value())) - } else { - sb.WriteString(styles.Title.Render(wrap(s.title, maxWidth))) - } - if s.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - } - return sb.String() -} - -func (s *Select[T]) descriptionView() string { - if s.description == "" { - return "" - } - maxWidth := s.width - s.activeStyles().Base.GetHorizontalFrameSize() - return s.activeStyles().Description.Render(wrap(s.description, maxWidth)) -} - -func (s *Select[T]) optionsView() (string, int, int) { - var ( - styles = s.activeStyles() - sb strings.Builder - ) - - if s.inline { - option := styles.TextInput.Placeholder.Render("No matches") - if len(s.filteredOptions) > 0 { - option = styles.SelectedOption.Render(s.filteredOptions[s.selected].Key) - } - return lipgloss.NewStyle(). - Width(s.width). - Render(lipgloss.JoinHorizontal( - lipgloss.Left, - styles.PrevIndicator.Faint(s.selected <= 0).String(), - option, - styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String(), - )), - -1, 1 - } - - var cursorOffset int - var cursorHeight int - for i, option := range s.filteredOptions { - selected := s.selected == i - line := s.renderOption(option, selected) - if i < s.selected { - cursorOffset += lipgloss.Height(line) - } - if selected { - cursorHeight = lipgloss.Height(line) - } - - sb.WriteString(line) - if i < len(s.options)-1 { - sb.WriteString("\n") - } - } - - for i := len(s.filteredOptions); i < len(s.options)-1; i++ { - sb.WriteString("\n") - } - - return sb.String(), cursorOffset, cursorHeight -} - -// cursorLineOffset computes the line offset and height (in lines) for the -// currently selected option without rendering the full options string. -func (s *Select[T]) cursorLineOffset() (offset int, height int) { - for i, option := range s.filteredOptions { - line := s.renderOption(option, s.selected == i) - h := lipgloss.Height(line) - if i < s.selected { - offset += h - } - if i == s.selected { - height = h - return offset, height - } - } - return offset, height -} - -// ensureVisible scrolls a viewport the minimum amount so that the region -// [offset, offset+height) is within the visible area. -func ensureVisible(vp *viewport.Model, offset, height int) { - if height <= 0 { - return - } - yOff := vp.YOffset() - vHeight := vp.Height() - if offset < yOff { - vp.ScrollUp(yOff - offset) - } else if offset+height > yOff+vHeight { - vp.ScrollDown(offset + height - yOff - vHeight) - } -} - -func (s *Select[T]) ensureCursorVisible() { - offset, height := s.cursorLineOffset() - ensureVisible(&s.viewport, offset, height) -} - -func (s *Select[T]) renderOption(option Option[T], selected bool) string { - var ( - styles = s.activeStyles() - cursor = styles.SelectSelector.String() - cursorW = lipgloss.Width(cursor) - maxWidth = s.width - s.activeStyles().Base.GetHorizontalFrameSize() - cursorW - ) - - key := wrap(option.Key, maxWidth) - - if selected { - return lipgloss.JoinHorizontal( - lipgloss.Left, - cursor, - styles.SelectedOption.Render(key), - ) - } - return lipgloss.JoinHorizontal( - lipgloss.Left, - strings.Repeat(" ", cursorW), - styles.UnselectedOption.Render(key), - ) -} - -// View renders the select field. -func (s *Select[T]) View() string { - styles := s.activeStyles() - vpc, _, _ := s.optionsView() - s.viewport.SetContent(vpc) - - var parts []string - if s.title != "" { - parts = append(parts, s.titleView()) - } - if s.description != "" { - parts = append(parts, s.descriptionView()) - } - parts = append(parts, s.viewport.View()) - return styles.Base.Width(s.width).Height(s.height). - Render(strings.Join(parts, "\n")) -} - -// clearFilter clears the value of the filter. -func (s *Select[T]) clearFilter() { - s.filter.SetValue("") - s.filteredOptions = s.options - s.setFiltering(false) -} - -// setFiltering sets the filter of the select field. -func (s *Select[T]) setFiltering(filtering bool) { - if s.inline && filtering { - s.filter.SetWidth(lipgloss.Width(s.titleView()) - 1 - 1) - } - s.filtering = filtering - s.keymap.SetFilter.SetEnabled(filtering) - s.keymap.Filter.SetEnabled(!filtering) - s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") -} - -// filterFunc returns true if the option matches the filter. -func (s *Select[T]) filterFunc(option string) bool { - // XXX: remove diacritics or allow customization of filter function. - return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) -} - -// RunAccessible runs an accessible select field. -func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error { - styles := s.activeStyles() - _, _ = fmt.Fprintln(w, styles.Title. - PaddingRight(1). - Render(cmp.Or(s.title, "Select:"))) - - for i, option := range s.options { - _, _ = fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) - } - - var defaultValue *int - switch s.accessor.(type) { - case *PointerAccessor[T]: // if its of this type, it means it has a default value - s.selectOption() // make sure s.selected is set - idx := s.selected + 1 - defaultValue = &idx - } - prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) - if len(s.options) == 1 { - prompt = "There is only one option available; enter the number 1:" - } - for { - choice := promptInt(w, r, prompt, 1, len(s.options), defaultValue) - option := s.options[choice-1] - if err := s.validate(option.Value); err != nil { - _, _ = fmt.Fprintln(w, err.Error()) - _, _ = fmt.Fprintln(w) - continue - } - s.accessor.Set(option.Value) - return nil - } -} - -// WithTheme sets the theme of the select field. -func (s *Select[T]) WithTheme(theme Theme) Field { - if s.theme != nil { - return s - } - s.theme = theme - styles := s.theme.Theme(s.hasDarkBg) - - st := s.filter.Styles() - st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.Focused.TextInput.Prompt - st.Focused.Text = styles.Focused.TextInput.Text - st.Focused.Placeholder = styles.Focused.TextInput.Placeholder - s.filter.SetStyles(st) - - s.updateViewportSize() - return s -} - -// WithKeyMap sets the keymap on a select field. -func (s *Select[T]) WithKeyMap(k *KeyMap) Field { - s.keymap = k.Select - s.keymap.Left.SetEnabled(s.inline) - s.keymap.Right.SetEnabled(s.inline) - s.keymap.Up.SetEnabled(!s.inline) - s.keymap.Down.SetEnabled(!s.inline) - return s -} - -// WithWidth sets the width of the select field. -func (s *Select[T]) WithWidth(width int) Field { - s.width = width - s.updateViewportSize() - return s -} - -// WithHeight sets the height of the select field. -func (s *Select[T]) WithHeight(height int) Field { - return s.Height(height) -} - -// WithPosition sets the position of the select field. -func (s *Select[T]) WithPosition(p FieldPosition) Field { - if s.filtering { - return s - } - s.keymap.Prev.SetEnabled(!p.IsFirst()) - s.keymap.Next.SetEnabled(!p.IsLast()) - s.keymap.Submit.SetEnabled(p.IsLast()) - return s -} - -// GetValue returns the value of the field. -func (s *Select[T]) GetValue() any { - return s.accessor.Get() -} - -// GetFiltering returns the filtering state of the field. -func (s *Select[T]) GetFiltering() bool { - return s.filtering -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl deleted file mode 100644 index 1b82adc7f..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/field_text.go.stmpl +++ /dev/null @@ -1,386 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "cmp" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Text is a text field. -// -// A text box is responsible for getting multi-line input from the user. Use -// it to gather longer-form user input. The Text field can be filled with an -// EDITOR. -type Text struct { - accessor Accessor[string] - - title string - description string - - externalEditor bool - editorCmd string - editorArgs []string - editorExtension string - - textarea textarea.Model - - focused bool - validate func(string) error - err error - - width int - - theme Theme - hasDarkBg bool - keymap TextKeyMap -} - -// NewText creates a new text field. -// -// A text box is responsible for getting multi-line input from the user. Use -// it to gather longer-form user input. The Text field can be filled with an -// EDITOR. -func NewText() *Text { - text := textarea.New() - text.ShowLineNumbers = false - text.Prompt = "" - st := text.Styles() - st.Focused.CursorLine = lipgloss.NewStyle() - text.SetStyles(st) - - editorCmd, editorArgs := getEditor() - - t := &Text{ - accessor: &EmbeddedAccessor[string]{}, - textarea: text, - validate: func(string) error { return nil }, - externalEditor: true, - editorCmd: editorCmd, - editorArgs: editorArgs, - editorExtension: "md", - } - - return t -} - -// Value sets the value of the text field. -func (t *Text) Value(value *string) *Text { - return t.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the text field. -func (t *Text) Accessor(accessor Accessor[string]) *Text { - t.accessor = accessor - t.textarea.SetValue(t.accessor.Get()) - return t -} - -// Title sets the text field's title. -func (t *Text) Title(title string) *Text { - t.title = title - return t -} - -// Description sets the description of the text field. -func (t *Text) Description(description string) *Text { - t.description = description - return t -} - -// Lines sets the number of lines to show of the text field. -func (t *Text) Lines(lines int) *Text { - t.textarea.SetHeight(lines) - return t -} - -// CharLimit sets the character limit of the text field. -func (t *Text) CharLimit(charlimit int) *Text { - t.textarea.CharLimit = charlimit - return t -} - -// ShowLineNumbers sets whether or not to show line numbers. -func (t *Text) ShowLineNumbers(show bool) *Text { - t.textarea.ShowLineNumbers = show - return t -} - -// Placeholder sets the placeholder of the text field. -// -// This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`. -func (t *Text) Placeholder(str string) *Text { - t.textarea.Placeholder = str - return t -} - -// Validate sets the validation function of the text field. -func (t *Text) Validate(validate func(string) error) *Text { - t.validate = validate - return t -} - -// ExternalEditor sets whether option to launch an editor is available. -func (t *Text) ExternalEditor(enabled bool) *Text { - t.externalEditor = enabled - return t -} - -const defaultEditor = "nano" - -// getEditor returns the editor command and arguments. -func getEditor() (string, []string) { - editor := strings.Fields(os.Getenv("EDITOR")) - if len(editor) > 0 { - return editor[0], editor[1:] - } - return defaultEditor, nil -} - -// Editor specifies which editor to use. -// -// The first argument provided is used as the editor command (vim, nvim, nano, etc...) -// The following (optional) arguments provided are passed as arguments to the editor command. -func (t *Text) Editor(editor ...string) *Text { - if len(editor) > 0 { - t.editorCmd = editor[0] - } - if len(editor) > 1 { - t.editorArgs = editor[1:] - } - return t -} - -// EditorExtension specifies arguments to pass into the editor. -func (t *Text) EditorExtension(extension string) *Text { - t.editorExtension = extension - return t -} - -// Error returns the error of the text field. -func (t *Text) Error() error { return t.err } - -// Skip returns whether the textarea should be skipped or should be blocking. -func (*Text) Skip() bool { return false } - -// Zoom returns whether the note should be zoomed. -func (*Text) Zoom() bool { return false } - -// Focus focuses the text field. -func (t *Text) Focus() tea.Cmd { - t.focused = true - return t.textarea.Focus() -} - -// Blur blurs the text field. -func (t *Text) Blur() tea.Cmd { - t.focused = false - t.accessor.Set(t.textarea.Value()) - t.textarea.Blur() - t.err = t.validate(t.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the text field. -func (t *Text) KeyBinds() []key.Binding { - t.keymap.Editor.SetEnabled(t.externalEditor) - return []key.Binding{t.keymap.NewLine, t.keymap.Editor, t.keymap.Prev, t.keymap.Submit, t.keymap.Next} -} - -type updateValueMsg []byte - -// Init initializes the text field. -func (t *Text) Init() tea.Cmd { - t.textarea.Blur() - return nil -} - -// Update updates the text field. -func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - t.hasDarkBg = msg.IsDark() - case updateValueMsg: - t.textarea.SetValue(string(msg)) - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - t.accessor.Set(t.textarea.Value()) - case updateFieldMsg: - return t, nil - case tea.KeyPressMsg: - t.err = nil - - switch { - case key.Matches(msg, t.keymap.Editor): - ext := strings.TrimPrefix(t.editorExtension, ".") - tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext) - cmd := exec.CommandContext( - context.TODO(), - t.editorCmd, - append(t.editorArgs, tmpFile.Name())..., - ) - _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) - cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg { - content, _ := os.ReadFile(tmpFile.Name()) - _ = os.Remove(tmpFile.Name()) - return updateValueMsg(content) - })) - case key.Matches(msg, t.keymap.Next, t.keymap.Submit): - value := t.textarea.Value() - t.err = t.validate(value) - if t.err != nil { - return t, nil - } - cmds = append(cmds, NextField) - case key.Matches(msg, t.keymap.Prev): - value := t.textarea.Value() - t.err = t.validate(value) - if t.err != nil { - return t, nil - } - cmds = append(cmds, PrevField) - } - } - - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - t.accessor.Set(t.textarea.Value()) - - return t, tea.Batch(cmds...) -} - -func (t *Text) activeStyles() *FieldStyles { - theme := t.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if t.focused { - return &theme.Theme(t.hasDarkBg).Focused - } - return &theme.Theme(t.hasDarkBg).Blurred -} - -// View renders the text field. -func (t *Text) View() string { - styles := t.activeStyles() - st := t.textarea.Styles() - - if t.focused { - st.Focused.Placeholder = styles.TextInput.Placeholder - st.Focused.Text = styles.TextInput.Text - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.CursorLine = styles.TextInput.Text - } else { - st.Blurred.Placeholder = styles.TextInput.Placeholder - st.Blurred.Text = styles.TextInput.Text - st.Blurred.Prompt = styles.TextInput.Prompt - st.Blurred.CursorLine = styles.TextInput.Text - } - st.Cursor.Color = styles.TextInput.Cursor.GetBackground() - t.textarea.SetStyles(st) - - maxWidth := t.width - styles.Base.GetHorizontalFrameSize() - var parts []string - if t.title != "" { - header := styles.Title.Render(wrap(t.title, maxWidth)) - if t.err != nil { - header += styles.ErrorIndicator.String() - } - parts = append(parts, header) - } - if t.description != "" { - parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) - } - parts = append(parts, t.textarea.View()) - - return styles.Base. - Render(strings.Join(parts, "\n")) -} - -// RunAccessible runs an accessible text field. -func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { - styles := t.activeStyles() - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(t.title, "Input:")) - t.accessor.Set(promptString( - w, - r, - prompt, - t.GetValue().(string), - func(input string) error { - if err := t.validate(input); err != nil { - // Handle the error from t.validate, return it - return err - } - - if t.textarea.CharLimit > 0 && len(input) > t.textarea.CharLimit { - return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit) - } - return nil - }, - )) - return nil -} - -// WithTheme sets the theme on a text field. -func (t *Text) WithTheme(theme Theme) Field { - if t.theme != nil { - return t - } - t.theme = theme - return t -} - -// WithKeyMap sets the keymap on a text field. -func (t *Text) WithKeyMap(k *KeyMap) Field { - t.keymap = k.Text - t.textarea.KeyMap.InsertNewline.SetKeys(t.keymap.NewLine.Keys()...) - return t -} - -// WithWidth sets the width of the text field. -func (t *Text) WithWidth(width int) Field { - t.width = width - t.textarea.SetWidth(width - t.activeStyles().Base.GetHorizontalFrameSize()) - return t -} - -// WithHeight sets the height of the text field. -func (t *Text) WithHeight(height int) Field { - adjust := 0 - if t.title != "" { - adjust++ - } - if t.description != "" { - adjust++ - } - t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) - return t -} - -// WithPosition sets the position information of the text field. -func (t *Text) WithPosition(p FieldPosition) Field { - t.keymap.Prev.SetEnabled(!p.IsFirst()) - t.keymap.Next.SetEnabled(!p.IsLast()) - t.keymap.Submit.SetEnabled(p.IsLast()) - return t -} - -// GetValue returns the value of the field. -func (t *Text) GetValue() any { - return t.accessor.Get() -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl deleted file mode 100644 index e37f99613..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/form.go.stmpl +++ /dev/null @@ -1,585 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "cmp" - "errors" - "fmt" - "io" - "os" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -const defaultWidth = 80 - -// Model is a Bubble Tea model whose Update returns the concrete field, group -// or form type rather than [tea.Model]. -type Model interface { - Init() tea.Cmd - Update(msg tea.Msg) (Model, tea.Cmd) - View() string -} - -// viewModel adapts a [Model] to [tea.Model]. -type viewModel struct { - Model - viewHook func(tea.View) tea.View -} - -// Update implements [tea.Model]. -func (w viewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m, cmd := w.Model.Update(msg) - return viewModel{ - Model: m, - viewHook: w.viewHook, - }, cmd -} - -// View implements [tea.Model]. -func (w viewModel) View() tea.View { - var view tea.View - if w.viewHook != nil { - view = w.viewHook(view) - } - view.SetContent(w.Model.View()) - return view -} - -// FormState represents the current state of the form. -type FormState int - -const ( - // StateNormal is when the user is completing the form. - StateNormal FormState = iota - - // StateCompleted is when the user has completed the form. - StateCompleted - - // StateAborted is when the user has aborted the form. - StateAborted -) - -// ErrUserAborted is the error returned when a user exits the form before submitting. -var ErrUserAborted = errors.New("user aborted") - -// Form is a collection of groups that are displayed one at a time on a "page". -// -// The form can navigate between groups and is complete once all the groups are -// complete. -type Form struct { - // collection of groups - selector *selector[*Group] - - // callbacks - SubmitCmd tea.Cmd - CancelCmd tea.Cmd - - State FormState - - // whether or not to use bubble tea rendering for accessibility - // purposes, if true, the form will render with basic prompting primitives - // to be more accessible to screen readers. - accessible bool - - quitting bool - aborted bool - - // options - width int - height int - theme Theme - hasDarkBg bool - keymap *KeyMap - teaOptions []tea.ProgramOption - - // accessible mode IO - output io.Writer - input io.Reader -} - -// NewForm returns a form with the given groups and default themes and -// keybindings. -// -// Use With* methods to customize the form with options, such as setting -// different themes and keybindings. -func NewForm(groups ...*Group) *Form { - selector := newSelector(groups) - - f := &Form{ - selector: selector, - keymap: NewDefaultKeyMap(), - teaOptions: []tea.ProgramOption{ - tea.WithOutput(os.Stderr), - }, - } - - // NB: If dynamic forms come into play this will need to be applied when - // groups and fields are added. - f.WithKeyMap(f.keymap) - f.WithWidth(f.width) - f.WithHeight(f.height) - f.UpdateFieldPositions() - - if os.Getenv("TERM") == "dumb" { - f.WithWidth(defaultWidth) - f.WithAccessible(true) - } - - return f -} - -// Field is a primitive of a form. -// -// A field represents a single input control on a form such as a text input, -// confirm button, select option, etc... -// -// Each field implements the Bubble Tea Model interface. -type Field interface { - // Bubble Tea Model - Model - - // Bubble Tea Events - Blur() tea.Cmd - Focus() tea.Cmd - - // Errors and Validation - Error() error - - // RunAccessible runs the field in accessible mode with the given IO. - RunAccessible(w io.Writer, r io.Reader) error - - // Skip returns whether this input should be skipped or not. - Skip() bool - - // Zoom returns whether this input should be zoomed or not. - // Zoom allows the field to take focus of the group / form height. - Zoom() bool - - // KeyBinds returns help keybindings. - KeyBinds() []key.Binding - - // WithTheme sets the theme on a field. - WithTheme(Theme) Field - - // WithKeyMap sets the keymap on a field. - WithKeyMap(*KeyMap) Field - - // WithWidth sets the width of a field. - WithWidth(int) Field - - // WithHeight sets the height of a field. - WithHeight(int) Field - - // WithPosition tells the field the index of the group and position it is in. - WithPosition(FieldPosition) Field - - // GetValue returns the field's value. - GetValue() any -} - -// FieldPosition is positional information about the given field and form. -type FieldPosition struct { - Group int - Field int - FirstField int - LastField int - GroupCount int - FirstGroup int - LastGroup int -} - -// IsFirst returns whether a field is the form's first field. -func (p FieldPosition) IsFirst() bool { - return p.Field == p.FirstField && p.Group == p.FirstGroup -} - -// IsLast returns whether a field is the form's last field. -func (p FieldPosition) IsLast() bool { - return p.Field == p.LastField && p.Group == p.LastGroup -} - -// nextGroupMsg is a message to move to the next group. -type nextGroupMsg struct{} - -// prevGroupMsg is a message to move to the previous group. -type prevGroupMsg struct{} - -// nextGroup is the command to move to the next group. -func nextGroup() tea.Msg { - return nextGroupMsg{} -} - -// prevGroup is the command to move to the previous group. -func prevGroup() tea.Msg { - return prevGroupMsg{} -} - -// WithAccessible sets the form to run in accessible mode to avoid redrawing the -// views which makes it easier for screen readers to read and describe the form. -// -// This avoids using the Bubble Tea renderer and instead simply uses basic -// terminal prompting to gather input which degrades the user experience but -// provides accessibility. -func (f *Form) WithAccessible(accessible bool) *Form { - f.accessible = accessible - return f -} - -// WithShowHelp sets whether or not the form should show help. -// -// This allows the form groups and field to show what keybindings are available -// to the user. -func (f *Form) WithShowHelp(v bool) *Form { - f.selector.Range(func(_ int, group *Group) bool { - group.WithShowHelp(v) - return true - }) - return f -} - -// WithTheme sets the theme on a form. -// -// This allows all groups and fields to be themed consistently, however themes -// can be applied to each group and field individually for more granular -// control. -func (f *Form) WithTheme(theme Theme) *Form { - if theme == nil { - return f - } - f.theme = theme - f.selector.Range(func(_ int, group *Group) bool { - group.WithTheme(theme) - return true - }) - return f -} - -// WithKeyMap sets the keymap on a form. -// -// This allows customization of the form key bindings. -func (f *Form) WithKeyMap(keymap *KeyMap) *Form { - if keymap == nil { - return f - } - f.keymap = keymap - f.selector.Range(func(_ int, group *Group) bool { - group.WithKeyMap(keymap) - return true - }) - f.UpdateFieldPositions() - return f -} - -// WithWidth sets the width of a form. -// -// This allows all groups and fields to be sized consistently, however width -// can be applied to each group and field individually for more granular -// control. -func (f *Form) WithWidth(width int) *Form { - if width <= 0 { - return f - } - f.width = width - f.selector.Range(func(_ int, group *Group) bool { - group.WithWidth(width) - return true - }) - return f -} - -// WithHeight sets the height of a form. -func (f *Form) WithHeight(height int) *Form { - if height <= 0 { - return f - } - f.height = height - f.selector.Range(func(_ int, group *Group) bool { - group.WithHeight(height) - return true - }) - return f -} - -// WithOutput sets the io.Writer to output the form. -// Default is STDOUT when [Form] is accessible (set with [Form.WithAccessible], STDERR otherwise. -func (f *Form) WithOutput(w io.Writer) *Form { - f.output = w - f.teaOptions = append(f.teaOptions, tea.WithOutput(w)) - return f -} - -// WithInput sets the io.Reader to the input form. -// Default is STDIN. -func (f *Form) WithInput(r io.Reader) *Form { - f.input = r - f.teaOptions = append(f.teaOptions, tea.WithInput(r)) - return f -} - -// UpdateFieldPositions sets the position on all the fields. -func (f *Form) UpdateFieldPositions() *Form { - firstGroup := 0 - lastGroup := f.selector.Total() - 1 - - // determine the first non-hidden group. - f.selector.Range(func(_ int, g *Group) bool { - if !f.isGroupHidden(g) { - return false - } - firstGroup++ - return true - }) - - // determine the last non-hidden group. - f.selector.ReverseRange(func(_ int, g *Group) bool { - if !f.isGroupHidden(g) { - return false - } - lastGroup-- - return true - }) - - f.selector.Range(func(g int, group *Group) bool { - // determine the first non-skippable field. - var firstField int - group.selector.Range(func(_ int, field Field) bool { - if !field.Skip() || group.selector.Total() == 1 { - return false - } - firstField++ - return true - }) - - // determine the last non-skippable field. - var lastField int - group.selector.ReverseRange(func(i int, field Field) bool { - lastField = i - if !field.Skip() || group.selector.Total() == 1 { - return false - } - return true - }) - - group.selector.Range(func(i int, field Field) bool { - field.WithPosition(FieldPosition{ - Group: g, - Field: i, - FirstField: firstField, - LastField: lastField, - FirstGroup: firstGroup, - LastGroup: lastGroup, - }) - return true - }) - - return true - }) - return f -} - -// Init initializes the form. -func (f *Form) Init() tea.Cmd { - var cmds []tea.Cmd - f.selector.Range(func(i int, group *Group) bool { - if i == 0 { - group.active = true - } - cmds = append(cmds, group.Init()) - return true - }) - - if f.isGroupHidden(f.selector.Selected()) { - cmds = append(cmds, nextGroup) - } - - cmds = append(cmds, tea.RequestWindowSize) - return tea.Sequence(cmds...) -} - -// Update updates the form. -func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) { - // If the form is aborted or completed there's no need to update it. - if f.State != StateNormal { - return f, nil - } - - group := f.selector.Selected() - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - f.hasDarkBg = msg.IsDark() - case tea.WindowSizeMsg: - if f.width == 0 { - f.selector.Range(func(_ int, group *Group) bool { - group.WithWidth(msg.Width) - return true - }) - } - if f.height == 0 { - // calculate the needed height, which is the height of the - // heightest group, accounting for the width, wraps, etc. - neededHeight := 0 - f.selector.Range(func(_ int, group *Group) bool { - neededHeight = max(neededHeight, group.rawHeight()) - return true - }) - - f.selector.Range(func(_ int, group *Group) bool { - group.WithHeight(min(neededHeight, msg.Height)) - return true - }) - } - - case tea.KeyPressMsg: - switch { - case key.Matches(msg, f.keymap.Quit): - f.aborted = true - f.quitting = true - f.State = StateAborted - return f, f.CancelCmd - } - - case nextGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - - submit := func() (Model, tea.Cmd) { - f.quitting = true - f.State = StateCompleted - return f, f.SubmitCmd - } - - if f.selector.OnLast() { - return submit() - } - - for i := f.selector.Index() + 1; i < f.selector.Total(); i++ { - if !f.isGroupHidden(f.selector.Get(i)) { - f.selector.SetIndex(i) - break - } - // all subsequent groups are hidden, so we must act as - // if we were in the last one. - if i == f.selector.Total()-1 { - return submit() - } - } - f.selector.Selected().active = true - return f, f.selector.Selected().Init() - - case prevGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - - for i := f.selector.Index() - 1; i >= 0; i-- { - if !f.isGroupHidden(f.selector.Get(i)) { - f.selector.SetIndex(i) - break - } - } - - f.selector.Selected().active = true - return f, f.selector.Selected().Init() - } - - m, cmd := group.Update(msg) - f.selector.Set(f.selector.Index(), m.(*Group)) - - // A user input a key, this could hide or show other groups, - // let's update all of their positions. - switch msg.(type) { - case tea.KeyPressMsg: - f.UpdateFieldPositions() - } - - return f, cmd -} - -func (f *Form) isGroupHidden(group *Group) bool { - hide := group.hide - if hide == nil { - return false - } - return hide() -} - -func (f *Form) getTheme() *Styles { - if f.theme != nil { - return f.theme.Theme(f.hasDarkBg) - } - return themeCharm(f.hasDarkBg) -} - -func (f *Form) styles() FormStyles { - return f.getTheme().Form -} - -// View renders the form. -func (f *Form) View() string { - if f.quitting { - return "" - } - - return f.styles().Base.Render(f.selector.Selected().View()) -} - -// Run runs the form. -func (f *Form) Run() error { - f.SubmitCmd = tea.Quit - f.CancelCmd = tea.Interrupt - - if f.selector.Total() == 0 { - return nil - } - - if f.accessible { - return f.runAccessible( - cmp.Or[io.Writer](f.output, os.Stdout), - cmp.Or[io.Reader](f.input, os.Stdin), - ) - } - - return f.run() -} - -// run runs the form in normal mode. -func (f *Form) run() error { - m, err := tea.NewProgram( - viewModel{ - Model: f, - viewHook: func(v tea.View) tea.View { - v.ReportFocus = true - return v - }, - }, - f.teaOptions..., - ).Run() - if m.(viewModel).Model.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { - return ErrUserAborted - } - if err != nil { - return fmt.Errorf("form: %w", err) - } - return nil -} - -// runAccessible runs the form in accessible mode. -func (f *Form) runAccessible(w io.Writer, r io.Reader) error { - f.selector.Range(func(_ int, group *Group) bool { - group.selector.Range(func(_ int, field Field) bool { - field.Init() - field.Focus() - _ = field.RunAccessible(w, r) - _, _ = fmt.Fprintln(w) - return true - }) - return true - }) - - return nil -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl new file mode 100644 index 000000000..9fab65734 --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -0,0 +1,1278 @@ +{{- if isAnyInteractiveEnabled}} +// MIT License +// +// Copyright (c) 2023–2026 Charm +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Package forms renders the terminal forms used by guided prompts: text +// inputs, multi-line text, selects, and confirmations, with an accessible +// line-prompt mode for screen readers and non-TTY input. +// +// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), +// reduced to the fields and form options this CLI uses. +package forms + +import ( + "bufio" + "cmp" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ErrUserAborted is returned by Run when the user cancels the form. +var ErrUserAborted = errors.New("user aborted") + +// Field is one input control on a form. +type Field interface { + Init() tea.Cmd + Update(msg tea.Msg) (Field, tea.Cmd) + View() string + Focus() tea.Cmd + Blur() tea.Cmd + Error() error + RunAccessible(w io.Writer, r io.Reader) error + + setWidth(width int) + setHeight(height int) + setPosition(first, last bool) +} + +type nextFieldMsg struct{} + +type prevFieldMsg struct{} + +type nextGroupMsg struct{} + +type prevGroupMsg struct{} + +func nextField() tea.Msg { return nextFieldMsg{} } + +func prevField() tea.Msg { return prevFieldMsg{} } + +func nextGroup() tea.Msg { return nextGroupMsg{} } + +func prevGroup() tea.Msg { return prevGroupMsg{} } + +// navKeys moves between fields. The form's first field has no previous +// field, and only enter submits from its last field. +type navKeys struct { + prev, next key.Binding +} + +func newNavKeys(next ...string) navKeys { + return navKeys{ + prev: key.NewBinding(key.WithKeys("shift+tab")), + next: key.NewBinding(key.WithKeys(next...)), + } +} + +func (n *navKeys) setPosition(first, last bool) { + n.prev.SetEnabled(!first) + if last { + n.next.SetKeys("enter") + } +} + +// Form shows one group of fields at a time and completes after the last group. +type Form struct { + groups []*Group + index int + + accessible bool + width int + quit key.Binding + + done bool + quitting bool + aborted bool + + submitCmd tea.Cmd + cancelCmd tea.Cmd +} + +// NewForm returns a form sized to the terminal width. +func NewForm(groups ...*Group) *Form { + f := &Form{ + groups: groups, + quit: key.NewBinding(key.WithKeys("ctrl+c")), + // Dumb terminals cannot redraw, so they get line prompts. + accessible: os.Getenv("TERM") == "dumb", + } + for g, group := range groups { + for i, field := range group.fields { + field.setPosition(g == 0 && i == 0, g == len(groups)-1 && i == len(group.fields)-1) + } + } + f.width = terminalWidth() + for _, group := range groups { + group.setWidth(f.width) + } + return f +} + +func terminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 80 + } + return width +} + +// WithAccessible replaces the interactive renderer with line prompts that +// read stdin, for screen readers and non-terminal input. +func (f *Form) WithAccessible(accessible bool) *Form { + f.accessible = accessible + return f +} + +// Run runs the form until it is submitted or cancelled. +func (f *Form) Run() error { + f.submitCmd = tea.Quit + f.cancelCmd = tea.Interrupt + if len(f.groups) == 0 { + return nil + } + if f.accessible { + for _, group := range f.groups { + for _, field := range group.fields { + field.Init() + field.Focus() + _ = field.RunAccessible(os.Stdout, os.Stdin) + fmt.Fprintln(os.Stdout) + } + } + return nil + } + m, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run() + if m.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + if err != nil { + return fmt.Errorf("form: %w", err) + } + return nil +} + +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + cmds := make([]tea.Cmd, 0, len(f.groups)+1) + for i, group := range f.groups { + group.active = i == 0 + cmds = append(cmds, group.Init()) + } + cmds = append(cmds, tea.RequestWindowSize) + return tea.Sequence(cmds...) +} + +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if f.done { + return f, nil + } + group := f.groups[f.index] + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // Size every group to the tallest one so pages don't jump. + needed := 0 + for _, g := range f.groups { + needed = max(needed, g.rawHeight()) + } + for _, g := range f.groups { + g.setHeight(min(needed, msg.Height)) + } + case tea.KeyPressMsg: + if key.Matches(msg, f.quit) { + f.aborted = true + f.quitting = true + f.done = true + return f, f.cancelCmd + } + case nextGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + if f.index == len(f.groups)-1 { + f.quitting = true + f.done = true + return f, f.submitCmd + } + f.index++ + f.groups[f.index].active = true + return f, f.groups[f.index].Init() + case prevGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + if f.index > 0 { + f.index-- + } + f.groups[f.index].active = true + return f, f.groups[f.index].Init() + } + + return f, group.Update(msg) +} + +// View implements [tea.Model]. +func (f *Form) View() tea.View { + var v tea.View + v.ReportFocus = true + v.SetContent(f.render()) + return v +} + +func (f *Form) render() string { + if f.quitting { + return "" + } + return lipgloss.NewStyle().Render(f.groups[f.index].View()) +} + +// Group is a page of fields shown together. +type Group struct { + fields []Field + index int + title string + + viewport viewport.Model + width int + height int + active bool +} + +// NewGroup returns a group of the given fields. +func NewGroup(fields ...Field) *Group { + g := &Group{fields: fields, width: 80} + g.height = g.rawHeight() + g.viewport = viewport.New(viewport.WithWidth(g.width), viewport.WithHeight(g.height)) + return g +} + +// Title sets the heading shown above the group's fields. +func (g *Group) Title(title string) *Group { + g.title = title + return g +} + +func (g *Group) setWidth(width int) { + g.width = width + g.viewport.SetWidth(width) + for _, field := range g.fields { + field.setWidth(width) + } +} + +func (g *Group) setHeight(height int) { + g.height = height + h := height - g.titleFooterHeight() + g.viewport.SetHeight(h) + for _, field := range g.fields { + // A field height must not exceed the form height. + if h < lipgloss.Height(field.View()) { + field.setHeight(h) + } + } +} + +// Errors returns the validation errors of the group's fields. +func (g *Group) Errors() []error { + var errs []error + for _, field := range g.fields { + if err := field.Error(); err != nil { + errs = append(errs, err) + } + } + return errs +} + +// Init focuses the current field when the group is active. +func (g *Group) Init() tea.Cmd { + var cmd tea.Cmd + if g.active { + cmd = g.fields[g.index].Focus() + } + g.buildView() + return cmd +} + +func (g *Group) nextField() []tea.Cmd { + blur := g.fields[g.index].Blur() + if g.index == len(g.fields)-1 { + return []tea.Cmd{blur, nextGroup} + } + g.index++ + return []tea.Cmd{blur, g.fields[g.index].Focus()} +} + +func (g *Group) prevField() []tea.Cmd { + blur := g.fields[g.index].Blur() + if g.index == 0 { + return []tea.Cmd{blur, prevGroup} + } + g.index-- + return []tea.Cmd{blur, g.fields[g.index].Focus()} +} + +// Update routes key presses and pastes to the focused field and every other +// message to all fields. +func (g *Group) Update(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i, field := range g.fields { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + default: + m, cmd := field.Update(msg) + g.fields[i] = m + cmds = append(cmds, cmd) + } + if i == g.index { + m, cmd := field.Update(msg) + g.fields[i] = m + cmds = append(cmds, cmd) + } + } + + switch msg.(type) { + case nextFieldMsg: + cmds = append(cmds, g.nextField()...) + case prevFieldMsg: + cmds = append(cmds, g.prevField()...) + } + + g.buildView() + return tea.Batch(cmds...) +} + +func (g *Group) content() (int, string) { + var fields strings.Builder + offset := 0 + for i, field := range g.fields { + fields.WriteString(field.View()) + if i == g.index { + offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) + } + if i < len(g.fields)-1 { + fields.WriteString("\n\n") + } + } + return offset, fields.String() +} + +func (g *Group) buildView() { + offset, content := g.content() + g.viewport.SetContent(content) + g.viewport.SetYOffset(offset) +} + +func (g *Group) header() string { + if g.title == "" { + return "" + } + return lipgloss.NewStyle().Render(wrap(g.title, g.width)) +} + +func (g *Group) footer() string { + var parts []string + for _, err := range g.Errors() { + parts = append(parts, wrap(focusedStyles.ErrorMessage.Render(err.Error()), g.width)) + } + return lipgloss.NewStyle().Render(strings.Join(parts, "\n")) +} + +func (g *Group) titleFooterHeight() int { + h := 0 + if s := g.header(); s != "" { + h += lipgloss.Height(s) + } + if s := g.footer(); s != "" { + h += lipgloss.Height(s) + } + return h +} + +// rawHeight is the group's full height without a viewport. +func (g *Group) rawHeight() int { + _, content := g.content() + return lipgloss.Height(content) + g.titleFooterHeight() +} + +// View renders the group. +func (g *Group) View() string { + var parts []string + if s := g.header(); s != "" { + parts = append(parts, s) + } + parts = append(parts, g.viewport.View()) + if s := g.footer(); s != "" { + parts = append(parts, "", s) + } + // A trailing space in the bottom-right cell scrolls some terminals. + last := len(parts) - 1 + parts[last] = strings.TrimSuffix(parts[last], " ") + return strings.Join(parts, "\n") +} + +func wrap(s string, limit int) string { + return lipgloss.Wrap(s, limit, ",.-; ") +} + +func noValidation(string) error { return nil } + +// Input is a single-line text field. +type Input struct { + value *string + title string + description string + textinput textinput.Model + validate func(string) error + err error + focused bool + width int + height int + nav navKeys +} + +// NewInput returns a single-line text field. +func NewInput() *Input { + return &Input{value: new(string), textinput: textinput.New(), validate: noValidation, nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value, which also supplies the initial text. +func (i *Input) Value(value *string) *Input { + i.value = value + i.textinput.SetValue(*value) + return i +} + +// Title sets the field title. +func (i *Input) Title(title string) *Input { i.title = title; return i } + +// Description sets the text shown under the title. +func (i *Input) Description(description string) *Input { i.description = description; return i } + +// Placeholder sets the text shown while the field is empty. +func (i *Input) Placeholder(placeholder string) *Input { + i.textinput.Placeholder = placeholder + return i +} + +// Password masks the typed characters. +func (i *Input) Password() *Input { + i.textinput.EchoMode = textinput.EchoPassword + return i +} + +// Validate sets a check that runs on submit and when the field loses focus. +func (i *Input) Validate(validate func(string) error) *Input { + i.validate = validate + return i +} + +func (i *Input) Error() error { return i.err } + +func (i *Input) Focus() tea.Cmd { + i.focused = true + return i.textinput.Focus() +} + +func (i *Input) Blur() tea.Cmd { + i.focused = false + *i.value = i.textinput.Value() + i.textinput.Blur() + i.err = i.validate(*i.value) + return nil +} + +func (i *Input) Init() tea.Cmd { + i.textinput.Blur() + return nil +} + +func (i *Input) Update(msg tea.Msg) (Field, tea.Cmd) { + var cmds []tea.Cmd + if msg, ok := msg.(tea.KeyPressMsg); ok { + i.err = nil + switch { + case key.Matches(msg, i.nav.prev): + cmds = append(cmds, prevField) + case key.Matches(msg, i.nav.next): + if i.err = i.validate(i.textinput.Value()); i.err != nil { + return i, nil + } + cmds = append(cmds, nextField) + } + } + var cmd tea.Cmd + i.textinput, cmd = i.textinput.Update(msg) + cmds = append(cmds, cmd) + *i.value = i.textinput.Value() + return i, tea.Batch(cmds...) +} + +func (i *Input) View() string { + styles := stylesFor(i.focused) + maxWidth := i.width - styles.Base.GetHorizontalFrameSize() + + st := i.textinput.Styles() + st.Cursor.Color = styles.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.Text = styles.TextInput.Text + st.Focused.Placeholder = styles.TextInput.Placeholder + i.textinput.SetStyles(st) + + var sb strings.Builder + if i.title != "" { + sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth)) + "\n") + } + if i.description != "" { + sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth)) + "\n") + } + sb.WriteString(i.textinput.View()) + return styles.Base.Width(i.width).Height(i.height).Render(sb.String()) +} + +func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { + title := stylesFor(i.focused).Title.PaddingRight(1) + if i.textinput.EchoMode == textinput.EchoNormal { + *i.value = promptString(w, r, title.Render(cmp.Or(i.title, "Input:")), *i.value, i.validate) + return nil + } + fd, ok := r.(interface{ Fd() uintptr }) + if !ok { + return errors.New("password asking needs a tty") + } + value, err := promptPassword(w, fd.Fd(), title.Render(cmp.Or(i.title, "Password:")), i.validate) + if err != nil { + return err + } + *i.value = value + return nil +} + +func (i *Input) setWidth(width int) { + i.width = width + frame := stylesFor(i.focused).Base.GetHorizontalFrameSize() + prompt := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) + i.textinput.SetWidth(width - frame - prompt - 1) +} + +func (i *Input) setHeight(height int) { i.height = height } + +func (i *Input) setPosition(first, last bool) { i.nav.setPosition(first, last) } + +// Text is a multi-line text field; alt+enter or ctrl+j inserts a newline. +type Text struct { + value *string + title string + description string + textarea textarea.Model + validate func(string) error + err error + focused bool + width int + nav navKeys +} + +// NewText returns a multi-line text field. +func NewText() *Text { + ta := textarea.New() + ta.ShowLineNumbers = false + ta.Prompt = "" + st := ta.Styles() + st.Focused.CursorLine = lipgloss.NewStyle() + ta.SetStyles(st) + ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + return &Text{value: new(string), textarea: ta, validate: noValidation, nav: newNavKeys("tab", "enter")} +} + +// Value binds the field to value, which also supplies the initial text. +func (t *Text) Value(value *string) *Text { + t.value = value + t.textarea.SetValue(*value) + return t +} + +// Title sets the field title. +func (t *Text) Title(title string) *Text { t.title = title; return t } + +// Description sets the text shown under the title. +func (t *Text) Description(description string) *Text { t.description = description; return t } + +// Validate sets a check that runs on submit and when the field loses focus. +func (t *Text) Validate(validate func(string) error) *Text { + t.validate = validate + return t +} + +func (t *Text) Error() error { return t.err } + +func (t *Text) Focus() tea.Cmd { + t.focused = true + return t.textarea.Focus() +} + +func (t *Text) Blur() tea.Cmd { + t.focused = false + *t.value = t.textarea.Value() + t.textarea.Blur() + t.err = t.validate(*t.value) + return nil +} + +func (t *Text) Init() tea.Cmd { + t.textarea.Blur() + return nil +} + +func (t *Text) Update(msg tea.Msg) (Field, tea.Cmd) { + var cmds []tea.Cmd + if msg, ok := msg.(tea.KeyPressMsg); ok { + t.err = nil + switch { + case key.Matches(msg, t.nav.next): + if t.err = t.validate(t.textarea.Value()); t.err != nil { + return t, nil + } + cmds = append(cmds, nextField) + case key.Matches(msg, t.nav.prev): + if t.err = t.validate(t.textarea.Value()); t.err != nil { + return t, nil + } + cmds = append(cmds, prevField) + } + } + var cmd tea.Cmd + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + *t.value = t.textarea.Value() + return t, tea.Batch(cmds...) +} + +func (t *Text) View() string { + styles := stylesFor(t.focused) + st := t.textarea.Styles() + state := &st.Blurred + if t.focused { + state = &st.Focused + } + state.Placeholder = styles.TextInput.Placeholder + state.Text = styles.TextInput.Text + state.Prompt = styles.TextInput.Prompt + state.CursorLine = styles.TextInput.Text + st.Cursor.Color = styles.TextInput.Cursor.GetBackground() + t.textarea.SetStyles(st) + + maxWidth := t.width - styles.Base.GetHorizontalFrameSize() + var parts []string + if t.title != "" { + header := styles.Title.Render(wrap(t.title, maxWidth)) + if t.err != nil { + header += styles.ErrorIndicator.String() + } + parts = append(parts, header) + } + if t.description != "" { + parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) + } + parts = append(parts, t.textarea.View()) + return styles.Base.Render(strings.Join(parts, "\n")) +} + +func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { + prompt := stylesFor(t.focused).Title.PaddingRight(1).Render(cmp.Or(t.title, "Input:")) + *t.value = promptString(w, r, prompt, *t.value, t.validate) + return nil +} + +func (t *Text) setWidth(width int) { + t.width = width + t.textarea.SetWidth(width - stylesFor(t.focused).Base.GetHorizontalFrameSize()) +} + +func (t *Text) setHeight(height int) { + adjust := 0 + if t.title != "" { + adjust++ + } + if t.description != "" { + adjust++ + } + t.textarea.SetHeight(height - stylesFor(t.focused).Base.GetVerticalFrameSize() - adjust) +} + +func (t *Text) setPosition(first, last bool) { t.nav.setPosition(first, last) } + +// Option is a choice in a [Select]: Key is shown, Value is stored. +type Option struct { + Key string + Value string +} + +// NewOption returns a select option. +func NewOption(key, value string) Option { + return Option{Key: key, Value: value} +} + +var ( + selectUp = key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p")) + selectDown = key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n")) + selectTop = key.NewBinding(key.WithKeys("home", "g")) + selectBottom = key.NewBinding(key.WithKeys("end", "G")) + selectHalfUp = key.NewBinding(key.WithKeys("ctrl+u")) + selectHalfDown = key.NewBinding(key.WithKeys("ctrl+d")) +) + +// Select picks one option from a list. +type Select struct { + value *string + title string + description string + options []Option + selected int + focused bool + width int + height int + viewport viewport.Model + nav navKeys +} + +// NewSelect returns a single-choice list. +func NewSelect() *Select { + return &Select{value: new(string), nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value and moves the cursor to the matching option. +func (s *Select) Value(value *string) *Select { + s.value = value + for i, o := range s.options { + if o.Value == *value { + s.selected = i + break + } + } + s.updateValue() + return s +} + +// Title sets the field title. +func (s *Select) Title(title string) *Select { s.title = title; return s } + +// Description sets the text shown under the title. +func (s *Select) Description(description string) *Select { s.description = description; return s } + +// Options sets the choices. +func (s *Select) Options(options ...Option) *Select { + if len(options) == 0 { + return s + } + s.options = options + s.selectCurrent() + s.updateViewportSize() + s.updateValue() + return s +} + +func (s *Select) selectCurrent() { + for i, o := range s.options { + if o.Value == *s.value { + s.selected = i + break + } + } + s.ensureCursorVisible() +} + +func (s *Select) Error() error { return nil } + +func (s *Select) Focus() tea.Cmd { + s.focused = true + return nil +} + +func (s *Select) Blur() tea.Cmd { + s.focused = false + return nil +} + +func (s *Select) Init() tea.Cmd { return nil } + +func (s *Select) Update(msg tea.Msg) (Field, tea.Cmd) { + s.updateViewportSize() + msgKey, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil + } + last := len(s.options) - 1 + switch { + case key.Matches(msgKey, selectUp): + s.selected-- + if s.selected < 0 { + s.selected = last + s.viewport.GotoBottom() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msgKey, selectTop): + s.selected = 0 + s.viewport.GotoTop() + s.updateValue() + case key.Matches(msgKey, selectBottom): + s.selected = last + s.viewport.GotoBottom() + case key.Matches(msgKey, selectHalfUp): + s.selected = max(s.selected-s.viewport.Height()/2, 0) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msgKey, selectHalfDown): + s.selected = min(s.selected+s.viewport.Height()/2, last) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msgKey, selectDown): + s.selected++ + if s.selected > last { + s.selected = 0 + s.viewport.GotoTop() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msgKey, s.nav.prev): + if s.selected > last { + break + } + s.updateValue() + return s, prevField + case key.Matches(msgKey, s.nav.next): + if s.selected > last { + break + } + s.updateValue() + return s, nextField + } + s.ensureCursorVisible() + return s, nil +} + +func (s *Select) updateValue() { + if s.selected >= 0 && s.selected < len(s.options) { + *s.value = s.options[s.selected].Value + } +} + +func (s *Select) updateViewportSize() { + if s.height > 0 { + yoffset := 0 + if v := s.titleView(); v != "" { + yoffset += lipgloss.Height(v) + } + if v := s.descriptionView(); v != "" { + yoffset += lipgloss.Height(v) + } + s.viewport.SetHeight(max(1, s.height-yoffset)) + s.ensureCursorVisible() + } else { + s.viewport.SetHeight(lipgloss.Height(s.optionsView())) + } + if s.width > 0 { + s.viewport.SetWidth(s.width) + } else { + s.viewport.SetWidth(lipgloss.Width(s.optionsView())) + } +} + +func (s *Select) maxWidth() int { + return s.width - stylesFor(s.focused).Base.GetHorizontalFrameSize() +} + +func (s *Select) titleView() string { + return stylesFor(s.focused).Title.Render(wrap(s.title, s.maxWidth())) +} + +func (s *Select) descriptionView() string { + if s.description == "" { + return "" + } + return stylesFor(s.focused).Description.Render(wrap(s.description, s.maxWidth())) +} + +func (s *Select) optionsView() string { + var sb strings.Builder + for i, option := range s.options { + sb.WriteString(s.renderOption(option, i == s.selected)) + if i < len(s.options)-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (s *Select) renderOption(option Option, selected bool) string { + styles := stylesFor(s.focused) + cursor := styles.SelectSelector.String() + cursorWidth := lipgloss.Width(cursor) + label := wrap(option.Key, s.maxWidth()-cursorWidth) + if selected { + return lipgloss.JoinHorizontal(lipgloss.Left, cursor, styles.SelectedOption.Render(label)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", cursorWidth), styles.UnselectedOption.Render(label)) +} + +// ensureCursorVisible scrolls the viewport the minimum amount to show the +// selected option. +func (s *Select) ensureCursorVisible() { + offset, height := 0, 0 + for i, option := range s.options { + h := lipgloss.Height(s.renderOption(option, i == s.selected)) + if i == s.selected { + height = h + break + } + offset += h + } + if height <= 0 { + return + } + if y := s.viewport.YOffset(); offset < y { + s.viewport.ScrollUp(y - offset) + } else if end := y + s.viewport.Height(); offset+height > end { + s.viewport.ScrollDown(offset + height - end) + } +} + +func (s *Select) View() string { + s.viewport.SetContent(s.optionsView()) + var parts []string + if s.title != "" { + parts = append(parts, s.titleView()) + } + if s.description != "" { + parts = append(parts, s.descriptionView()) + } + parts = append(parts, s.viewport.View()) + return stylesFor(s.focused).Base.Width(s.width).Height(s.height).Render(strings.Join(parts, "\n")) +} + +func (s *Select) RunAccessible(w io.Writer, r io.Reader) error { + fmt.Fprintln(w, stylesFor(s.focused).Title.PaddingRight(1).Render(cmp.Or(s.title, "Select:"))) + for i, option := range s.options { + fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) + } + s.selectCurrent() + prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) + if len(s.options) == 1 { + prompt = "There is only one option available; enter the number 1:" + } + choice := promptInt(w, r, prompt, 1, len(s.options), s.selected+1) + *s.value = s.options[choice-1].Value + return nil +} + +func (s *Select) setWidth(width int) { + s.width = width + s.updateViewportSize() +} + +func (s *Select) setHeight(height int) { + s.height = height + s.updateViewportSize() +} + +func (s *Select) setPosition(first, last bool) { s.nav.setPosition(first, last) } + +var ( + confirmToggle = key.NewBinding(key.WithKeys("h", "l", "right", "left")) + confirmAccept = key.NewBinding(key.WithKeys("y", "Y")) + confirmReject = key.NewBinding(key.WithKeys("n", "N")) +) + +// Confirm is a yes/no choice. +type Confirm struct { + value *bool + title string + description string + focused bool + width int + height int + nav navKeys +} + +// NewConfirm returns a yes/no choice. +func NewConfirm() *Confirm { + return &Confirm{value: new(bool), nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value, which also supplies the initial choice. +func (c *Confirm) Value(value *bool) *Confirm { c.value = value; return c } + +// Title sets the field title. +func (c *Confirm) Title(title string) *Confirm { c.title = title; return c } + +// Description sets the text shown under the title. +func (c *Confirm) Description(description string) *Confirm { c.description = description; return c } + +func (c *Confirm) Error() error { return nil } + +func (c *Confirm) Focus() tea.Cmd { + c.focused = true + return nil +} + +func (c *Confirm) Blur() tea.Cmd { + c.focused = false + return nil +} + +func (c *Confirm) Init() tea.Cmd { return nil } + +func (c *Confirm) Update(msg tea.Msg) (Field, tea.Cmd) { + msgKey, ok := msg.(tea.KeyPressMsg) + if !ok { + return c, nil + } + switch { + case key.Matches(msgKey, confirmToggle): + *c.value = !*c.value + case key.Matches(msgKey, c.nav.prev): + return c, prevField + case key.Matches(msgKey, c.nav.next): + return c, nextField + case key.Matches(msgKey, confirmAccept): + *c.value = true + return c, nextField + case key.Matches(msgKey, confirmReject): + *c.value = false + return c, nextField + } + return c, nil +} + +func (c *Confirm) View() string { + styles := stylesFor(c.focused) + maxWidth := c.width - styles.Base.GetHorizontalFrameSize() + + var sb strings.Builder + if c.title != "" { + sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) + } + if c.description != "" { + sb.WriteString("\n" + styles.Description.Render(wrap(c.description, maxWidth))) + } + if c.title != "" || c.description != "" { + sb.WriteString("\n\n") + } + + yes, no := styles.BlurredButton.Render("Yes"), styles.FocusedButton.Render("No") + if *c.value { + yes, no = styles.FocusedButton.Render("Yes"), styles.BlurredButton.Render("No") + } + buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, no) + width := max(lipgloss.Width(buttons), lipgloss.Width(sb.String())) + sb.WriteString(lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(buttons)) + return styles.Base.Width(c.width).Height(c.height).Render(sb.String()) +} + +func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { + opts := "[y/N]" + if *c.value { + opts = "[Y/n]" + } + prompt := stylesFor(c.focused).Title.PaddingRight(1).Render(cmp.Or(c.title, "Choose"), opts) + *c.value = promptBool(w, r, prompt, *c.value) + return nil +} + +func (c *Confirm) setWidth(width int) { c.width = width } + +func (c *Confirm) setHeight(height int) { c.height = height } + +func (c *Confirm) setPosition(first, last bool) { c.nav.setPosition(first, last) } + +type fieldStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + ErrorIndicator lipgloss.Style + ErrorMessage lipgloss.Style + + SelectSelector lipgloss.Style + SelectedOption lipgloss.Style + UnselectedOption lipgloss.Style + + FocusedButton lipgloss.Style + BlurredButton lipgloss.Style + + TextInput struct { + Cursor lipgloss.Style + Placeholder lipgloss.Style + Prompt lipgloss.Style + Text lipgloss.Style + } +} + +var focusedStyles, blurredStyles = newStyles() + +func newStyles() (focused, blurred fieldStyles) { + accent := {{themeAccent}} + dimmed := {{themeDimmed}} + subtle := {{themeSubtle}} + errColor := {{themeError}} + + button := lipgloss.NewStyle().Padding(0, 2).MarginRight(1) + + focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) + focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") + focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") + focused.SelectSelector = lipgloss.NewStyle().SetString("> ") + focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) + focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) + focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + blurred = focused + blurred.Base = blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + + focused.Base = focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) + focused.Title = focused.Title.Foreground(accent).Bold(true) + focused.Description = focused.Description.Foreground(dimmed).Italic(true) + focused.ErrorIndicator = focused.ErrorIndicator.Foreground(errColor) + focused.ErrorMessage = focused.ErrorMessage.Foreground(errColor) + focused.SelectSelector = focused.SelectSelector.Foreground(accent).SetString("> ") + focused.SelectedOption = focused.SelectedOption.Foreground(accent).Bold(true) + focused.FocusedButton = focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) + focused.BlurredButton = focused.BlurredButton.Background(subtle) + focused.TextInput.Cursor = focused.TextInput.Cursor.Foreground(accent) + focused.TextInput.Placeholder = focused.TextInput.Placeholder.Foreground(subtle).Italic(true) + focused.TextInput.Prompt = focused.TextInput.Prompt.Foreground(accent) + + blurred.Base = blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) + blurred.Title = blurred.Title.Foreground(dimmed) + blurred.Description = blurred.Description.Foreground(subtle).Italic(true) + blurred.TextInput.Text = blurred.TextInput.Text.Foreground(dimmed) + blurred.TextInput.Placeholder = blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) + blurred.SelectedOption = blurred.SelectedOption.Foreground(dimmed) + blurred.SelectSelector = blurred.SelectSelector.Foreground(dimmed) + return focused, blurred +} + +func stylesFor(focused bool) *fieldStyles { + if focused { + return &focusedStyles + } + return &blurredStyles +} + +// promptString prints prompt and reads a line until validate accepts it. A +// blank answer, or end of input, yields defaultValue. +func promptString(out io.Writer, in io.Reader, prompt, defaultValue string, validate func(string) error) string { + scanner := bufio.NewScanner(in) + var input string + for { + fmt.Fprint(out, prompt) + if !scanner.Scan() { + fmt.Fprintln(out) + break + } + input = scanner.Text() + if err := validate(input); err != nil { + fmt.Fprintln(out, err) + continue + } + break + } + return cmp.Or(strings.TrimSpace(input), defaultValue) +} + +// promptInt reads a number in [low, high]; a blank answer picks defaultValue. +func promptInt(out io.Writer, in io.Reader, prompt string, low, high, defaultValue int) int { + input := promptString(out, in, prompt, strconv.Itoa(defaultValue), func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if i, err := strconv.Atoi(s); err == nil && i >= low && i <= high { + return nil + } + if low == high { + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be %d", low) + } + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) + }) + choice, _ := strconv.Atoi(input) + return choice +} + +func parseBool(s string) (bool, error) { + switch strings.ToLower(s) { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + } + return false, errors.New("invalid input. please try again") +} + +// promptBool reads y/yes/n/no; a blank answer keeps defaultValue. +func promptBool(out io.Writer, in io.Reader, prompt string, defaultValue bool) bool { + def := "N" + if defaultValue { + def = "y" + } + input := promptString(out, in, prompt, def, func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + _, err := parseBool(s) + return err + }) + b, _ := parseBool(input) + return b +} + +// promptPassword reads a line from the terminal fd without echoing it. +func promptPassword(out io.Writer, fd uintptr, prompt string, validate func(string) error) (string, error) { + for { + fmt.Fprint(out, prompt) + pwd, err := term.ReadPassword(int(fd)) + if err != nil { + return "", err + } + fmt.Fprintln(out) + if err := validate(string(pwd)); err != nil { + fmt.Fprintln(out, err) + continue + } + return string(pwd), nil + } +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl deleted file mode 100644 index 9e1cd3ecb..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/group.go.stmpl +++ /dev/null @@ -1,410 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Group is a collection of fields that are displayed together with a page of -// the form. While a group is displayed the form completer can switch between -// fields in the group. -// -// If any of the fields in a group have errors, the form will not be able to -// progress to the next group. -type Group struct { - // collection of fields - selector *selector[Field] - - // information - title string - description string - - // navigation - viewport viewport.Model - - // help - showHelp bool - help help.Model - - // errors - showErrors bool - - // group options - width int - height int - theme Theme - hasDarkBg bool - keymap *KeyMap - hide func() bool - active bool -} - -// NewGroup returns a new group with the given fields. -func NewGroup(fields ...Field) *Group { - selector := newSelector(fields) - group := &Group{ - selector: selector, - help: help.New(), - showHelp: true, - showErrors: true, - active: false, - } - - group.width = 80 - height := group.rawHeight() - v := viewport.New( - viewport.WithWidth(group.width), - viewport.WithHeight(height), - ) - group.viewport = v - group.height = height - - return group -} - -// Title sets the group's title. -func (g *Group) Title(title string) *Group { - g.title = title - return g -} - -// Description sets the group's description. -func (g *Group) Description(description string) *Group { - g.description = description - return g -} - -// WithShowHelp sets whether or not the group's help should be shown. -func (g *Group) WithShowHelp(show bool) *Group { - g.showHelp = show - return g -} - -// WithShowErrors sets whether or not the group's errors should be shown. -func (g *Group) WithShowErrors(show bool) *Group { - g.showErrors = show - return g -} - -// WithTheme sets the theme on a group. -func (g *Group) WithTheme(t Theme) *Group { - g.theme = t - g.help.Styles = t.Theme(g.hasDarkBg).Help - g.selector.Range(func(_ int, field Field) bool { - field.WithTheme(t) - return true - }) - if g.height <= 0 { - g.WithHeight(g.rawHeight()) - } - return g -} - -// WithKeyMap sets the keymap on a group. -func (g *Group) WithKeyMap(k *KeyMap) *Group { - g.keymap = k - g.selector.Range(func(_ int, field Field) bool { - field.WithKeyMap(k) - return true - }) - return g -} - -// WithWidth sets the width on a group. -func (g *Group) WithWidth(width int) *Group { - g.width = width - g.viewport.SetWidth(width) - g.help.SetWidth(width) - g.selector.Range(func(_ int, field Field) bool { - field.WithWidth(width) - return true - }) - return g -} - -// WithHeight sets the height on a group. -func (g *Group) WithHeight(height int) *Group { - g.height = height - h := height - g.titleFooterHeight() - g.viewport.SetHeight(h) - g.selector.Range(func(_ int, field Field) bool { - // A field height must not exceed the form height. - if h < lipgloss.Height(field.View()) { - field.WithHeight(h) - } - return true - }) - return g -} - -// WithHide sets whether this group should be skipped. -func (g *Group) WithHide(hide bool) *Group { - g.WithHideFunc(func() bool { return hide }) - return g -} - -// WithHideFunc sets the function that checks if this group should be skipped. -func (g *Group) WithHideFunc(hideFunc func() bool) *Group { - g.hide = hideFunc - return g -} - -// Errors returns the groups' fields' errors. -func (g *Group) Errors() []error { - var errs []error - g.selector.Range(func(_ int, field Field) bool { - if err := field.Error(); err != nil { - errs = append(errs, err) - } - return true - }) - return errs -} - -// updateFieldMsg is a message to update the fields of a group that is currently -// displayed. -// -// This is used to update all TitleFunc, DescriptionFunc, and ...Func update -// methods to make all fields dynamically update based on user input. -type updateFieldMsg struct{} - -// nextFieldMsg is a message to move to the next field, -// -// each field controls when to send this message such that it is able to use -// different key bindings or events to trigger group progression. -type nextFieldMsg struct{} - -// prevFieldMsg is a message to move to the previous field. -// -// each field controls when to send this message such that it is able to use -// different key bindings or events to trigger group progression. -type prevFieldMsg struct{} - -// NextField is the command to move to the next field. -func NextField() tea.Msg { - return nextFieldMsg{} -} - -// PrevField is the command to move to the previous field. -func PrevField() tea.Msg { - return prevFieldMsg{} -} - -// Init initializes the group. -func (g *Group) Init() tea.Cmd { - var cmds []tea.Cmd - - cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) - - if g.selector.Selected().Skip() { - if g.selector.OnLast() { - cmds = append(cmds, g.prevField()...) - } else if g.selector.OnFirst() { - cmds = append(cmds, g.nextField()...) - } - return tea.Batch(cmds...) - } - - if g.active { - cmd := g.selector.Selected().Focus() - cmds = append(cmds, cmd) - } - g.buildView() - return tea.Batch(cmds...) -} - -// nextField moves to the next field. -func (g *Group) nextField() []tea.Cmd { - blurCmd := g.selector.Selected().Blur() - if g.selector.OnLast() { - return []tea.Cmd{blurCmd, nextGroup} - } - g.selector.Next() - for g.selector.Selected().Skip() { - if g.selector.OnLast() { - return []tea.Cmd{blurCmd, nextGroup} - } - g.selector.Next() - } - focusCmd := g.selector.Selected().Focus() - return []tea.Cmd{blurCmd, focusCmd} -} - -// prevField moves to the previous field. -func (g *Group) prevField() []tea.Cmd { - blurCmd := g.selector.Selected().Blur() - if g.selector.OnFirst() { - return []tea.Cmd{blurCmd, prevGroup} - } - g.selector.Prev() - for g.selector.Selected().Skip() { - if g.selector.OnFirst() { - return []tea.Cmd{blurCmd, prevGroup} - } - g.selector.Prev() - } - focusCmd := g.selector.Selected().Focus() - return []tea.Cmd{blurCmd, focusCmd} -} - -// Update updates the group. -func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - - // Update all the fields in the group. - g.selector.Range(func(i int, field Field) bool { - switch msg := msg.(type) { - case tea.KeyPressMsg, tea.PasteMsg: - break - default: - m, cmd := field.Update(msg) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - } - if g.selector.Index() == i { - m, cmd := field.Update(msg) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - } - m, cmd := field.Update(updateFieldMsg{}) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - return true - }) - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - g.hasDarkBg = msg.IsDark() - case nextFieldMsg: - cmds = append(cmds, g.nextField()...) - case prevFieldMsg: - cmds = append(cmds, g.prevField()...) - } - - g.buildView() - - return g, tea.Batch(cmds...) -} - -func (g *Group) getTheme() *Styles { - if theme := g.theme; theme != nil { - return theme.Theme(g.hasDarkBg) - } - return ThemeFunc(themeCharm).Theme(g.hasDarkBg) -} - -func (g *Group) styles() GroupStyles { return g.getTheme().Group } - -func (g *Group) getContent() (int, string) { - var fields strings.Builder - offset := 0 - - gap := g.getTheme().FieldSeparator.Render() - - // if the focused field is requesting it be zoomed, only show that field. - if g.selector.Selected().Zoom() { - g.selector.Selected().WithHeight(g.height) - fields.WriteString(g.selector.Selected().View()) - } else { - g.selector.Range(func(i int, field Field) bool { - fields.WriteString(field.View()) - if i == g.selector.Index() { - offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) - } - if i < g.selector.Total()-1 { - fields.WriteString(gap) - } - return true - }) - } - - return offset, fields.String() -} - -func (g *Group) buildView() { - offset, content := g.getContent() - g.viewport.SetContent(content) - g.viewport.SetYOffset(offset) -} - -// Header renders the group's header only (no content). -func (g *Group) Header() string { - styles := g.styles() - var parts []string - if g.title != "" { - parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) - } - if g.description != "" { - parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) - } - return strings.Join(parts, "\n") -} - -// titleFooterHeight returns the height of the footer + header. -func (g *Group) titleFooterHeight() int { - h := 0 - if s := g.Header(); s != "" { - h += lipgloss.Height(s) - } - if s := g.Footer(); s != "" { - h += lipgloss.Height(s) - } - return h -} - -// rawHeight returns the full height of the group, without using a viewport. -func (g *Group) rawHeight() int { - return lipgloss.Height(g.Content()) + g.titleFooterHeight() -} - -// View renders the group. -func (g *Group) View() string { - var parts []string - if s := g.Header(); s != "" { - parts = append(parts, s) - } - parts = append(parts, g.viewport.View()) - if s := g.Footer(); s != "" { - // append an empty line, and the footer (usually the help). - parts = append(parts, "", s) - } - if len(parts) > 0 { - // Trim suffix spaces from the last part as it can accidentally - // scroll the view up on some terminals (like Apple's Terminal.app) - // when we right to the bottom rightmost corner cell. - lastIdx := len(parts) - 1 - parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") - } - return strings.Join(parts, "\n") -} - -// Content renders the group's content only (no footer). -func (g *Group) Content() string { - _, content := g.getContent() - return content -} - -// Footer renders the group's footer only (no content). -func (g *Group) Footer() string { - var parts []string - errors := g.Errors() - if g.showHelp && len(errors) <= 0 { - parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) - } - if g.showErrors { - for _, err := range errors { - parts = append(parts, wrap( - g.getTheme().Focused.ErrorMessage.Render(err.Error()), - g.width, - )) - } - } - return g.styles().Base. - Render(strings.Join(parts, "\n")) -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl deleted file mode 100644 index b0d1e78e7..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/keymap.go.stmpl +++ /dev/null @@ -1,104 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import "charm.land/bubbles/v2/key" - -// KeyMap is the keybindings to navigate the form. -type KeyMap struct { - Quit key.Binding - - Confirm ConfirmKeyMap - Input InputKeyMap - Select SelectKeyMap - Text TextKeyMap -} - -// InputKeyMap is the keybindings for input fields. -type InputKeyMap struct { - AcceptSuggestion key.Binding - Next key.Binding - Prev key.Binding - Submit key.Binding -} - -// TextKeyMap is the keybindings for text fields. -type TextKeyMap struct { - Next key.Binding - Prev key.Binding - NewLine key.Binding - Editor key.Binding - Submit key.Binding -} - -// SelectKeyMap is the keybindings for select fields. -type SelectKeyMap struct { - Next key.Binding - Prev key.Binding - Up key.Binding - Down key.Binding - HalfPageUp key.Binding - HalfPageDown key.Binding - GotoTop key.Binding - GotoBottom key.Binding - Left key.Binding - Right key.Binding - Filter key.Binding - SetFilter key.Binding - ClearFilter key.Binding - Submit key.Binding -} - -// ConfirmKeyMap is the keybindings for confirm fields. -type ConfirmKeyMap struct { - Next key.Binding - Prev key.Binding - Toggle key.Binding - Submit key.Binding - Accept key.Binding - Reject key.Binding -} - -// NewDefaultKeyMap returns a new default keymap. -func NewDefaultKeyMap() *KeyMap { - return &KeyMap{ - Quit: key.NewBinding(key.WithKeys("ctrl+c")), - Input: InputKeyMap{ - AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - }, - Text: TextKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), - Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), - }, - Select: SelectKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), - Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), - Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), - Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), - SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), - ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), - HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), - HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), - GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), - GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), - }, - Confirm: ConfirmKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), - Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), - Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), - }, - } -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl deleted file mode 100644 index 7dafc9aab..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/option.go.stmpl +++ /dev/null @@ -1,19 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -// Option is an option for select fields. -type Option[T comparable] struct { - Key string - Value T -} - -// NewOption returns a new select option. -func NewOption[T comparable](key string, value T) Option[T] { - return Option[T]{Key: key, Value: value} -} - -// String returns the key of the option. -func (o Option[T]) String() string { - return o.Key -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl deleted file mode 100644 index b6bda8549..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/selector.go.stmpl +++ /dev/null @@ -1,98 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -// selector is a helper type for selecting items. -type selector[T any] struct { - items []T - index int -} - -// newSelector creates a new item selector. -func newSelector[T any](items []T) *selector[T] { - return &selector[T]{ - items: items, - } -} - -// Append adds an item to the selector. -func (s *selector[T]) Append(item T) { - s.items = append(s.items, item) -} - -// Next moves the selector to the next item. -func (s *selector[T]) Next() { - if s.index < len(s.items)-1 { - s.index++ - } -} - -// Prev moves the selector to the previous item. -func (s *selector[T]) Prev() { - if s.index > 0 { - s.index-- - } -} - -// OnFirst returns true if the selector is on the first item. -func (s *selector[T]) OnFirst() bool { - return s.index == 0 -} - -// OnLast returns true if the selector is on the last item. -func (s *selector[T]) OnLast() bool { - return s.index == len(s.items)-1 -} - -// Selected returns the index of the current selected item. -func (s *selector[T]) Selected() T { - return s.items[s.index] -} - -// Index returns the index of the current selected item. -func (s *selector[T]) Index() int { - return s.index -} - -// Total returns the total number of items. -func (s *selector[T]) Total() int { - return len(s.items) -} - -// SetIndex sets the selected item. -func (s *selector[T]) SetIndex(i int) { - if i < 0 || i >= len(s.items) { - return - } - s.index = i -} - -// Get returns the item at the given index. -func (s *selector[T]) Get(i int) T { - return s.items[i] -} - -// Set sets the item at the given index. -func (s *selector[T]) Set(i int, item T) { - s.items[i] = item -} - -// Range iterates over the items. -// The callback function should return true to continue the iteration. -func (s *selector[T]) Range(f func(i int, item T) bool) { - for i, item := range s.items { - if !f(i, item) { - break - } - } -} - -// ReverseRange iterates over the items in reverse. -// The callback function should return true to continue the iteration. -func (s *selector[T]) ReverseRange(f func(i int, item T) bool) { - for i := len(s.items) - 1; i >= 0; i-- { - if !f(i, s.items[i]) { - break - } - } -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl deleted file mode 100644 index d2ac85e10..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/theme.go.stmpl +++ /dev/null @@ -1,157 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "charm.land/bubbles/v2/help" - "charm.land/lipgloss/v2" -) - -// Theme represents a theme for a form. -type Theme interface { - Theme(isDark bool) *Styles -} - -// ThemeFunc is a function that returns a new theme. -type ThemeFunc func(isDark bool) *Styles - -// Theme implements the Theme interface. -func (f ThemeFunc) Theme(isDark bool) *Styles { - return f(isDark) -} - -// Styles is a collection of styles for components of the form. -// Themes can be applied to a form using the WithTheme option. -type Styles struct { - Form FormStyles - Group GroupStyles - FieldSeparator lipgloss.Style - Blurred FieldStyles - Focused FieldStyles - Help help.Styles -} - -// FormStyles are the styles for a form. -type FormStyles struct { - Base lipgloss.Style -} - -// GroupStyles are the styles for a group. -type GroupStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style -} - -// FieldStyles are the styles for input fields. -type FieldStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - ErrorIndicator lipgloss.Style - ErrorMessage lipgloss.Style - - // Select styles. - SelectSelector lipgloss.Style // Selection indicator - NextIndicator lipgloss.Style - PrevIndicator lipgloss.Style - SelectedOption lipgloss.Style - UnselectedOption lipgloss.Style - - // Textinput and teatarea styles. - TextInput TextInputStyles - - // Confirm styles. - FocusedButton lipgloss.Style - BlurredButton lipgloss.Style -} - -// TextInputStyles are the styles for text inputs. -type TextInputStyles struct { - Cursor lipgloss.Style - CursorText lipgloss.Style - Placeholder lipgloss.Style - Prompt lipgloss.Style - Text lipgloss.Style -} - -const ( - buttonPaddingHorizontal = 2 - buttonPaddingVertical = 0 -) - -// ThemeBase returns a new base theme with general styles to be inherited by -// other themes. -func ThemeBase(bool) *Styles { - var t Styles - - t.Form.Base = lipgloss.NewStyle() - t.Group.Base = lipgloss.NewStyle() - t.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") - - button := lipgloss.NewStyle(). - Padding(buttonPaddingVertical, buttonPaddingHorizontal). - MarginRight(1) - - // Focused styles. - t.Focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) - t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") - t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") - t.Focused.SelectSelector = lipgloss.NewStyle().SetString("> ") - t.Focused.NextIndicator = lipgloss.NewStyle().MarginLeft(1).SetString("→") - t.Focused.PrevIndicator = lipgloss.NewStyle().MarginRight(1).SetString("←") - t.Focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) - t.Focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) - t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - t.Help = help.New().Styles - - // Blurred styles. - t.Blurred = t.Focused - t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - t.Blurred.NextIndicator = lipgloss.NewStyle() - t.Blurred.PrevIndicator = lipgloss.NewStyle() - - return &t -} - -// themeCharm returns a new theme based on the Charm color scheme. -func themeCharm(isDark bool) *Styles { - t := ThemeBase(isDark) - lightDark := lipgloss.LightDark(isDark) - - var ( - normalFg = lightDark(lipgloss.Color("252"), lipgloss.Color("235")) - indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) - cream = lightDark(lipgloss.Color("#FFFDF5"), lipgloss.Color("#FFFDF5")) - fuchsia = lipgloss.Color("#F780E2") - green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) - red = lightDark(lipgloss.Color("#FF4672"), lipgloss.Color("#ED567A")) - ) - - t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("238")) - t.Focused.Title = t.Focused.Title.Foreground(indigo).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(fuchsia) - t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(fuchsia) - t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(fuchsia) - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) - t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(normalFg) - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(fuchsia) - t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lightDark(lipgloss.Color("237"), lipgloss.Color("252"))) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(green) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("238"))) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(fuchsia) - - t.Blurred = t.Focused - t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) - t.Blurred.NextIndicator = lipgloss.NewStyle() - t.Blurred.PrevIndicator = lipgloss.NewStyle() - - t.Group.Title = t.Focused.Title - t.Group.Description = t.Focused.Description - return t -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl deleted file mode 100644 index 7cef6fa2e..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/wrap.go.stmpl +++ /dev/null @@ -1,9 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import "charm.land/lipgloss/v2" - -func wrap(s string, limit int) string { - return lipgloss.Wrap(s, limit, ",.-; ") -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index 564872449..d08f9a8ea 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -16,7 +16,6 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" - "golang.org/x/term" "{{getGolangPackage}}/internal/config" "{{getGolangPackage}}/internal/flagutil" @@ -610,51 +609,6 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing forms, with a sensible default. -func formWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} - -// formTheme builds the interactive prompt theme. -func formTheme() forms.Theme { - return forms.ThemeFunc(formStyles) -} - -func formStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - - t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - return &t -} - func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { accent := {{themeAccent}} dimmed := {{themeDimmed}} @@ -702,9 +656,8 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) - theme, width := formTheme(), formWidth() for _, index := range required { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -713,14 +666,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field, theme, width) + answer, err := runPromptField(field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -736,14 +689,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))). - WithWidth(width).WithTheme(theme).WithShowHelp(false) + Description(strings.Join(names, ", ")).Value(&fillOptional))) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -760,9 +712,9 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { +func runPromptField(field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field, theme, width) + return runRepeatableFlagPrompt(field) } var answer PromptAnswer @@ -774,7 +726,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) answer.Set = true answer.Values = []string{strconv.FormatBool(value)} - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -782,10 +734,10 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw return answer, nil } value := "" - formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( + formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options( forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), ).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -793,15 +745,15 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw case "enum": value := "" - options := make([]forms.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option, 0, len(field.Options)+1) if !field.Required { - options = append(options, forms.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption("Skip (use default)", "")) } for _, option := range field.Options { options = append(options, forms.NewOption(option, option)) } - formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -871,7 +823,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw } formField = input } - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -881,7 +833,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { +func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -900,7 +852,7 @@ func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (P return nil }) } - form := forms.NewForm(forms.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(input)) if err := form.Run(); err != nil { return PromptAnswer{}, err } diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index d8b5dab1a..8590c1058 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -14,10 +14,7 @@ {{- addImport "fmt" -}} {{- addImport "encoding/json" -}} {{- if isInteractiveAuthEnabled -}} -{{- addImport "os" -}} -{{- addImport "golang.org/x/term" -}} {{- addImport "internal/forms" true -}} -{{- addImport "charm.land/lipgloss/v2" -}} {{- else -}} {{- addImport "bufio" -}} {{- addImport "strings" -}} @@ -104,11 +101,7 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) } - form := forms.NewForm(groups...). - WithAccessible(accessible). - WithTheme(configureFormTheme()). - WithWidth(configureFormWidth()). - WithShowHelp(false) + form := forms.NewForm(groups...).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) @@ -202,60 +195,3 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { } return true } - -{{- if isInteractiveAuthEnabled}} -// configureFormTheme builds the form theme for the configure command. -func configureFormTheme() forms.Theme { - return forms.ThemeFunc(configureFormStyles) -} - -func configureFormStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - - return &t -} - -// configureFormWidth returns the terminal width for sizing forms. -func configureFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} -{{- end}} diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 559e0768d..a37e7850e 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2152,7 +2152,7 @@ registerTemplateFunc( function templateConfigurePreferenceFormFields(): string { const lines: string[] = []; - lines.push(`forms.NewSelect[string]().`); + lines.push(`forms.NewSelect().`); lines.push(` Title("Default output format").`); lines.push( ` Description("Choose the default response rendering format for this CLI").`, @@ -2305,7 +2305,7 @@ registerTemplateFunc( /** * Generate form field construction for the auth login form. * Each security field is mapped to the appropriate form component: - * - Secrets → forms.NewInput().EchoMode(forms.EchoModePassword) + * - Secrets → forms.NewInput().Password() * - Non-secrets → forms.NewInput() * - Arrays → forms.NewInput() with comma-separated hint */ @@ -2342,7 +2342,7 @@ function templateAuthLoginFormFields(): string { field.field, )}").`, ); - lines.push(` EchoMode(forms.EchoModePassword).`); + lines.push(` Password().`); lines.push( ` Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, @@ -2603,11 +2603,9 @@ function templateAuthLoginBody(): string { lines.push(`// #region custom-auth-groups`); lines.push(`// #endregion custom-auth-groups`); lines.push(``); - lines.push(`form := forms.NewForm(forms.NewGroup(fields...)).`); - lines.push(` WithAccessible(accessible).`); - lines.push(` WithTheme(authFormTheme()).`); - lines.push(` WithWidth(authFormWidth()).`); - lines.push(` WithShowHelp(false)`); + lines.push( + `form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible)`, + ); lines.push(``); lines.push(`if err := form.Run(); err != nil {`); lines.push(` return fmt.Errorf("auth login: %w", err)`); @@ -2619,7 +2617,7 @@ function templateAuthLoginBody(): string { lines.push(`accessible := formMode == interactive.FormAccessible`); lines.push(``); lines.push(`var selectedScheme string`); - lines.push(`schemeSelect := forms.NewSelect[string]().`); + lines.push(`schemeSelect := forms.NewSelect().`); lines.push(` Title("Authentication Method").`); lines.push(` Description("Choose which credentials to configure").`); lines.push(` Options(`); @@ -2634,7 +2632,7 @@ function templateAuthLoginBody(): string { lines.push(` Value(&selectedScheme)`); lines.push(``); lines.push( - `if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil {`, + `if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2654,11 +2652,9 @@ function templateAuthLoginBody(): string { ); lines.push(` }`); lines.push(``); - lines.push(` form := forms.NewForm(forms.NewGroup(fields...)).`); - lines.push(` WithAccessible(accessible).`); - lines.push(` WithTheme(authFormTheme()).`); - lines.push(` WithWidth(authFormWidth()).`); - lines.push(` WithShowHelp(false)`); + lines.push( + ` form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible)`, + ); lines.push(``); lines.push(` if err := form.Run(); err != nil {`); lines.push(` return fmt.Errorf("auth login: %w", err)`); @@ -2882,7 +2878,7 @@ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { field.field, )}").`, ); - lines.push(` EchoMode(forms.EchoModePassword).`); + lines.push(` Password().`); lines.push( ` Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index a898b07b0..491837116 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -60,13 +60,13 @@ trackedFiles: go.mod: last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:d34149dac5264dd61b305d979adb072174bda1c4 + last_write_checksum: sha1:f038ee2d6544c3a19715539ded7e3ff9d6f35af2 internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:d5ccc0970afac880e050719fe49c883432062904 internal/cli/configure.go: - last_write_checksum: sha1:13ece24b90a03f30902ade4176a558afce3ca198 + last_write_checksum: sha1:8585233e3a49552c702ef1abde8d862e61efac6b internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -249,36 +249,10 @@ trackedFiles: last_write_checksum: sha1:1936c82d8e36a6612364827ffc05dcbea0e1dd8f internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a - internal/forms/accessible.go: - last_write_checksum: sha1:93ae0d80160d1ef815e1451281a4240b9a6e5c1b - internal/forms/accessor.go: - last_write_checksum: sha1:d2f23cf8fca78124e481a2f945e5e7e7bf924e5d - internal/forms/doc.go: - last_write_checksum: sha1:163e19b520eb18d3cfa405afc766a16371bb56db - internal/forms/field_confirm.go: - last_write_checksum: sha1:64e65c0371aca887fefa0c85ea7f1a921f8deabc - internal/forms/field_input.go: - last_write_checksum: sha1:3b2044da717ae5d9461d560309de742edd5f5554 - internal/forms/field_select.go: - last_write_checksum: sha1:cc9fa18cabced90d0d7bed6f4ebd3a0808aadb80 - internal/forms/field_text.go: - last_write_checksum: sha1:5ba863567bbd29d44b4af8c5ce6c98ca4134c576 - internal/forms/form.go: - last_write_checksum: sha1:7b4cccdc3ecf0f36876211afb539ef2ce7f059b1 - internal/forms/group.go: - last_write_checksum: sha1:c2f1361f63dd88876459f895885d30e6c448f8d8 - internal/forms/keymap.go: - last_write_checksum: sha1:609e9c9c2c7c4f853009642d12b845c17d484362 - internal/forms/option.go: - last_write_checksum: sha1:3669db405886fb0d4d1495089a6d1331ac781b04 - internal/forms/selector.go: - last_write_checksum: sha1:18bc62ce4de6aa3e38a663c60f289b65ae547f58 - internal/forms/theme.go: - last_write_checksum: sha1:fcb1a9bb680603c44dac9e0f272d13b437cb1c7f - internal/forms/wrap.go: - last_write_checksum: sha1:34c2c387acfbe04a76bb6e81103e5466064c02b8 + internal/forms/forms.go: + last_write_checksum: sha1:6c871926327419a05adb8f1064ff30c030cda018 internal/interactive/interactive.go: - last_write_checksum: sha1:ec3c54636b52c8d0ce2b3a1b605c5309ab91eeba + last_write_checksum: sha1:e5574ecb0c290ecb3dae6e94edcf7190b5335070 internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index dc8475e0f..ade568dd8 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -3,16 +3,13 @@ package cli import ( - "charm.land/lipgloss/v2" "cmp" "fmt" "github.com/spf13/cobra" - "golang.org/x/term" "openapi/internal/config" "openapi/internal/flagutil" "openapi/internal/forms" "openapi/internal/interactive" - "os" ) // initAuthCmd registers the auth command group with login, whoami, and logout subcommands. @@ -188,7 +185,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { accessible := formMode == interactive.FormAccessible var selectedScheme string - schemeSelect := forms.NewSelect[string](). + schemeSelect := forms.NewSelect(). Title("Authentication Method"). Description("Choose which credentials to configure"). Options( @@ -202,7 +199,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { ). Value(&selectedScheme) - if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).WithTheme(authFormTheme()).WithWidth(authFormWidth()).WithShowHelp(false).Run(); err != nil { + if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -220,16 +217,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("HTTP Basic password"). Description("--password"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). Value(&authPassword), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -253,22 +246,18 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). Value(&authBearerAuth), forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -293,16 +282,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). Value(&authOauth2), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -322,22 +307,18 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). Value(&authAppId), forms.NewInput(). Title("Custom authentication credential"). Description("--secret"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). Value(&authSecret), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -362,16 +343,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). Value(&authMobileAuth), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -392,13 +369,13 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). Value(&authClientID), forms.NewInput(). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). Value(&authClientSecret), forms.NewInput(). @@ -408,11 +385,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Value(&authTokenURL), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -441,16 +414,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), } - form := forms.NewForm(forms.NewGroup(fields...)). - WithAccessible(accessible). - WithTheme(authFormTheme()). - WithWidth(authFormWidth()). - WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -540,58 +509,3 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } - -// authFormTheme builds the form theme for auth login. -func authFormTheme() forms.Theme { - return forms.ThemeFunc(authFormStyles) -} - -func authFormStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - - return &t -} - -// authFormWidth returns the terminal width for sizing forms. -func authFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index 8b8e754ce..e044548a0 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -3,12 +3,10 @@ package cli import ( - "charm.land/lipgloss/v2" "cmp" "encoding/json" "fmt" "github.com/spf13/cobra" - "golang.org/x/term" "openapi/internal/client" "openapi/internal/config" "openapi/internal/flagutil" @@ -16,7 +14,6 @@ import ( "openapi/internal/interactive" "openapi/internal/output" "openapi/internal/usage" - "os" "strings" ) @@ -194,55 +191,55 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { forms.NewInput(). Title("HTTP Basic password"). Description("--password"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). Value(&authPassword), forms.NewInput(). Title("HTTP Bearer"). Description("--bearer-auth"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). Value(&authBearerAuth), forms.NewInput(). Title("API Key"). Description("--my-api-key"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). Value(&authMyApiKey), forms.NewInput(). Title("OAuth2 Authorization"). Description("--oauth2"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). Value(&authOauth2), forms.NewInput(). Title("Custom authentication credential"). Description("--app-id"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). Value(&authAppId), forms.NewInput(). Title("Custom authentication credential"). Description("--secret"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). Value(&authSecret), forms.NewInput(). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). Value(&authMobileAuth), forms.NewInput(). Title("Client Credentials flow. client identifier"). Description("--client-id"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). Value(&authClientID), forms.NewInput(). Title("Client Credentials flow. client secret"). Description("--client-secret"). - EchoMode(forms.EchoModePassword). + Password(). Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). Value(&authClientSecret), forms.NewInput(). @@ -281,7 +278,7 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { var cfgOutputFormat string if !accessible { preferenceFields := []forms.Field{ - forms.NewSelect[string](). + forms.NewSelect(). Title("Default output format"). Description("Choose the default response rendering format for this CLI"). Options( @@ -298,11 +295,7 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) } - form := forms.NewForm(groups...). - WithAccessible(accessible). - WithTheme(configureFormTheme()). - WithWidth(configureFormWidth()). - WithShowHelp(false) + form := forms.NewForm(groups...).WithAccessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) @@ -427,58 +420,3 @@ func dryRunLocalNoop(cmd *cobra.Command, message string) bool { } return true } - -// configureFormTheme builds the form theme for the configure command. -func configureFormTheme() forms.Theme { - return forms.ThemeFunc(configureFormStyles) -} - -func configureFormStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - - t.Focused.Base = t.Focused.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(accent). - PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base. - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(subtle). - PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - - return &t -} - -// configureFormWidth returns the terminal width for sizing forms. -func configureFormWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} diff --git a/zSDKs/sdk-cli/internal/forms/accessible.go b/zSDKs/sdk-cli/internal/forms/accessible.go deleted file mode 100644 index 928e006ca..000000000 --- a/zSDKs/sdk-cli/internal/forms/accessible.go +++ /dev/null @@ -1,182 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "bufio" - "cmp" - "errors" - "fmt" - "io" - "slices" - "strconv" - "strings" - - "golang.org/x/term" -) - -func atoi(s string) (int, error) { - if strings.TrimSpace(s) == "" { - return -1, nil - } - return strconv.Atoi(s) -} - -// promptInt prompts a user for an integer between a certain range. -// -// Given invalid input (non-integers, integers outside of the range), the user -// will continue to be reprompted until a valid input is given, ensuring that -// the return value is always valid. -func promptInt( - out io.Writer, - in io.Reader, - prompt string, - low, high int, - defaultValue *int, -) int { - var choice int - - validInt := func(s string) error { - if strings.TrimSpace(s) == "" && defaultValue != nil { - return nil - } - i, err := atoi(s) - if err != nil || i < low || i > high { - if low == high { - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be %d", low) - } - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) - } - return nil - } - - input := promptString( - out, - in, - prompt, - ptrToStr(defaultValue, strconv.Itoa), - validInt, - ) - choice, _ = strconv.Atoi(input) - return choice -} - -func parseBool(s string) (bool, error) { - s = strings.ToLower(s) - - if slices.Contains([]string{"y", "yes"}, s) { - return true, nil - } - - // As a special case, we default to "" to no since the usage of this - // function suggests N is the default. - if slices.Contains([]string{"n", "no"}, s) { - return false, nil - } - - return false, errors.New("invalid input. please try again") -} - -// promptBool prompts a user for a boolean value. -// -// Given invalid input (non-boolean), the user will continue to be reprompted -// until a valid input is given, ensuring that the return value is always valid. -func promptBool( - out io.Writer, - in io.Reader, - prompt string, - defaultValue bool, -) bool { - validBool := func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - _, err := parseBool(s) - return err - } - - input := promptString( - out, in, prompt, - boolToStr(defaultValue), - validBool, - ) - b, _ := parseBool(input) - return b -} - -// promptPassword allows to prompt for a password. -// In must be the fd of a tty. -func promptPassword( - out io.Writer, - in uintptr, - prompt string, - validator func(input string) error, -) (string, error) { - for { - _, _ = fmt.Fprint(out, prompt) - pwd, err := term.ReadPassword(int(in)) - if err != nil { - return "", err - } - _, _ = fmt.Fprintln(out) - if err := validator(string(pwd)); err != nil { - _, _ = fmt.Fprintln(out, err) - continue - } - return string(pwd), nil - } -} - -// promptString prompts a user for a string value and validates it against a -// validator function. It re-prompts the user until a valid input is given. -func promptString( - out io.Writer, - in io.Reader, - prompt string, - defaultValue string, - validator func(input string) error, -) string { - scanner := bufio.NewScanner(in) - - var ( - valid bool - input string - ) - - for !valid { - _, _ = fmt.Fprint(out, prompt) - if !scanner.Scan() { - // no way to bubble up errors or signal cancellation - // but the program is probably not continuing if - // stdin sent EOF - _, _ = fmt.Fprintln(out) - break - } - input = scanner.Text() - - if err := validator(input); err != nil { - _, _ = fmt.Fprintln(out, err) - continue - } - - break - } - - return cmp.Or(strings.TrimSpace(input), defaultValue) -} - -func ptrToStr[T any](t *T, fn func(t T) string) string { - if t == nil { - return "" - } - return fn(*t) -} - -func boolToStr(b bool) string { - if b { - return "y" - } - return "N" -} diff --git a/zSDKs/sdk-cli/internal/forms/accessor.go b/zSDKs/sdk-cli/internal/forms/accessor.go deleted file mode 100644 index 53274d8e2..000000000 --- a/zSDKs/sdk-cli/internal/forms/accessor.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -// Accessor give read/write access to field values. -type Accessor[T any] interface { - Get() T - Set(value T) -} - -// EmbeddedAccessor is a basic accessor, acting as the default one for fields. -type EmbeddedAccessor[T any] struct { - value T -} - -// Get gets the value. -func (a *EmbeddedAccessor[T]) Get() T { - return a.value -} - -// Set sets the value. -func (a *EmbeddedAccessor[T]) Set(value T) { - a.value = value -} - -// PointerAccessor allows field value to be exposed as a pointed variable. -type PointerAccessor[T any] struct { - value *T -} - -// NewPointerAccessor returns a new pointer accessor. -func NewPointerAccessor[T any](value *T) *PointerAccessor[T] { - return &PointerAccessor[T]{ - value: value, - } -} - -// Get gets the value. -func (a *PointerAccessor[T]) Get() T { - return *a.value -} - -// Set sets the value. -func (a *PointerAccessor[T]) Set(value T) { - *a.value = value -} diff --git a/zSDKs/sdk-cli/internal/forms/doc.go b/zSDKs/sdk-cli/internal/forms/doc.go deleted file mode 100644 index 07c2f280a..000000000 --- a/zSDKs/sdk-cli/internal/forms/doc.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -// MIT License -// -// Copyright (c) 2023–2026 Charm -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -// Package forms renders the terminal forms used by guided prompts: text -// inputs, multi-line text, selects, and confirmations, with an accessible -// line-prompt mode for screen readers and non-TTY input. -// -// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), -// reduced to the fields and form options this CLI uses. -package forms diff --git a/zSDKs/sdk-cli/internal/forms/field_confirm.go b/zSDKs/sdk-cli/internal/forms/field_confirm.go deleted file mode 100644 index 325d28b7d..000000000 --- a/zSDKs/sdk-cli/internal/forms/field_confirm.go +++ /dev/null @@ -1,308 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "cmp" - "io" - "strings" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Confirm is a form confirm field. -type Confirm struct { - accessor Accessor[bool] - - // customization - title string - description string - affirmative string - negative string - - // error handling - validate func(bool) error - err error - - // state - focused bool - - // options - width int - height int - inline bool - theme Theme - hasDarkBg bool - keymap ConfirmKeyMap - buttonAlignment lipgloss.Position -} - -// NewConfirm returns a new confirm field. -func NewConfirm() *Confirm { - return &Confirm{ - accessor: &EmbeddedAccessor[bool]{}, - affirmative: "Yes", - negative: "No", - validate: func(bool) error { return nil }, - buttonAlignment: lipgloss.Center, - } -} - -// Validate sets the validation function of the confirm field. -func (c *Confirm) Validate(validate func(bool) error) *Confirm { - c.validate = validate - return c -} - -// Error returns the error of the confirm field. -func (c *Confirm) Error() error { - return c.err -} - -// Skip returns whether the confirm should be skipped or should be blocking. -func (*Confirm) Skip() bool { - return false -} - -// Zoom returns whether the input should be zoomed. -func (*Confirm) Zoom() bool { - return false -} - -// Affirmative sets the affirmative value of the confirm field. -func (c *Confirm) Affirmative(affirmative string) *Confirm { - c.affirmative = affirmative - return c -} - -// Negative sets the negative value of the confirm field. -func (c *Confirm) Negative(negative string) *Confirm { - c.negative = negative - return c -} - -// Value sets the value of the confirm field. -func (c *Confirm) Value(value *bool) *Confirm { - return c.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the confirm field. -func (c *Confirm) Accessor(accessor Accessor[bool]) *Confirm { - c.accessor = accessor - return c -} - -// Title sets the title of the confirm field. -func (c *Confirm) Title(title string) *Confirm { - c.title = title - return c -} - -// Description sets the description of the confirm field. -func (c *Confirm) Description(description string) *Confirm { - c.description = description - return c -} - -// Inline sets whether the field should be inline. -func (c *Confirm) Inline(inline bool) *Confirm { - c.inline = inline - return c -} - -// Focus focuses the confirm field. -func (c *Confirm) Focus() tea.Cmd { - c.focused = true - return nil -} - -// Blur blurs the confirm field. -func (c *Confirm) Blur() tea.Cmd { - c.focused = false - c.err = c.validate(c.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the confirm field. -func (c *Confirm) KeyBinds() []key.Binding { - return []key.Binding{c.keymap.Toggle, c.keymap.Prev, c.keymap.Submit, c.keymap.Next, c.keymap.Accept, c.keymap.Reject} -} - -// Init initializes the confirm field. -func (c *Confirm) Init() tea.Cmd { - return nil -} - -// Update updates the confirm field. -func (c *Confirm) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - c.hasDarkBg = msg.IsDark() - case tea.KeyPressMsg: - c.err = nil - switch { - case key.Matches(msg, c.keymap.Toggle): - if c.negative == "" { - break - } - c.accessor.Set(!c.accessor.Get()) - case key.Matches(msg, c.keymap.Prev): - cmds = append(cmds, PrevField) - case key.Matches(msg, c.keymap.Next, c.keymap.Submit): - cmds = append(cmds, NextField) - case key.Matches(msg, c.keymap.Accept): - c.accessor.Set(true) - cmds = append(cmds, NextField) - case key.Matches(msg, c.keymap.Reject): - c.accessor.Set(false) - cmds = append(cmds, NextField) - } - } - - return c, tea.Batch(cmds...) -} - -func (c *Confirm) activeStyles() *FieldStyles { - theme := c.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if c.focused { - return &theme.Theme(c.hasDarkBg).Focused - } - return &theme.Theme(c.hasDarkBg).Blurred -} - -// View renders the confirm field. -func (c *Confirm) View() string { - styles := c.activeStyles() - maxWidth := c.width - styles.Base.GetHorizontalFrameSize() - - var wroteHeader bool - var sb strings.Builder - if c.title != "" { - sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) - wroteHeader = true - } - if c.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - wroteHeader = true - } - - if c.description != "" { - description := styles.Description.Render(wrap(c.description, maxWidth)) - if !c.inline && c.description != "" { - sb.WriteString("\n") - } - sb.WriteString(description) - wroteHeader = true - } - - if !c.inline && wroteHeader { - sb.WriteString("\n") - sb.WriteString("\n") - } - - var negative string - var affirmative string - if c.negative != "" { - if c.accessor.Get() { - affirmative = styles.FocusedButton.Render(c.affirmative) - negative = styles.BlurredButton.Render(c.negative) - } else { - affirmative = styles.BlurredButton.Render(c.affirmative) - negative = styles.FocusedButton.Render(c.negative) - } - c.keymap.Reject.SetHelp("n", c.negative) - } else { - affirmative = styles.FocusedButton.Render(c.affirmative) - c.keymap.Reject.SetEnabled(false) - } - - c.keymap.Accept.SetHelp("y", c.affirmative) - - buttonsRow := lipgloss.JoinHorizontal(c.buttonAlignment, affirmative, negative) - - promptWidth := lipgloss.Width(sb.String()) - buttonsWidth := lipgloss.Width(buttonsRow) - - renderWidth := max(buttonsWidth, promptWidth) - - style := lipgloss.NewStyle().Width(renderWidth).Align(c.buttonAlignment) - - sb.WriteString(style.Render(buttonsRow)) - return styles.Base.Width(c.width).Height(c.height). - Render(sb.String()) -} - -// RunAccessible runs the confirm field in accessible mode. -func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { - styles := c.activeStyles() - defaultValue := c.GetValue().(bool) - opts := "[y/N]" - if defaultValue { - opts = "[Y/n]" - } - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(c.title, "Choose"), opts) - c.accessor.Set(promptBool(w, r, prompt, defaultValue)) - return nil -} - -func (c *Confirm) String() string { - if c.accessor.Get() { - return c.affirmative - } - return c.negative -} - -// WithTheme sets the theme of the confirm field. -func (c *Confirm) WithTheme(theme Theme) Field { - if c.theme != nil { - return c - } - c.theme = theme - return c -} - -// WithKeyMap sets the keymap of the confirm field. -func (c *Confirm) WithKeyMap(k *KeyMap) Field { - c.keymap = k.Confirm - return c -} - -// WithWidth sets the width of the confirm field. -func (c *Confirm) WithWidth(width int) Field { - c.width = width - return c -} - -// WithHeight sets the height of the confirm field. -func (c *Confirm) WithHeight(height int) Field { - c.height = height - return c -} - -// WithPosition sets the position of the confirm field. -func (c *Confirm) WithPosition(p FieldPosition) Field { - c.keymap.Prev.SetEnabled(!p.IsFirst()) - c.keymap.Next.SetEnabled(!p.IsLast()) - c.keymap.Submit.SetEnabled(p.IsLast()) - return c -} - -// WithButtonAlignment sets the button position of the confirm field. -func (c *Confirm) WithButtonAlignment(p lipgloss.Position) *Confirm { - c.buttonAlignment = p - return c -} - -// GetValue returns the value of the field. -func (c *Confirm) GetValue() any { - return c.accessor.Get() -} diff --git a/zSDKs/sdk-cli/internal/forms/field_input.go b/zSDKs/sdk-cli/internal/forms/field_input.go deleted file mode 100644 index 049323167..000000000 --- a/zSDKs/sdk-cli/internal/forms/field_input.go +++ /dev/null @@ -1,345 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "cmp" - "errors" - "fmt" - "io" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Input is a input field. -// -// The input field is a field that allows the user to enter text. Use it to user -// input. It can be used for collecting text, passwords, or other short input. -// -// The input field supports Suggestions, Placeholder, and Validation. -type Input struct { - accessor Accessor[string] - - title string - description string - - textinput textinput.Model - - inline bool - validate func(string) error - err error - focused bool - - width int - height int - - theme Theme - hasDarkBg bool - keymap InputKeyMap -} - -// NewInput creates a new input field. -// -// The input field is a field that allows the user to enter text. Use it to user -// input. It can be used for collecting text, passwords, or other short input. -// -// The input field supports Suggestions, Placeholder, and Validation. -func NewInput() *Input { - input := textinput.New() - - i := &Input{ - accessor: &EmbeddedAccessor[string]{}, - textinput: input, - validate: func(string) error { return nil }, - } - - return i -} - -// Value sets the value of the input field. -func (i *Input) Value(value *string) *Input { - return i.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the input field. -func (i *Input) Accessor(accessor Accessor[string]) *Input { - i.accessor = accessor - i.textinput.SetValue(i.accessor.Get()) - return i -} - -// Title sets the title of the input field. -func (i *Input) Title(title string) *Input { - i.title = title - return i -} - -// Description sets the description of the input field. -func (i *Input) Description(description string) *Input { - i.description = description - return i -} - -// Prompt sets the prompt of the input field. -func (i *Input) Prompt(prompt string) *Input { - i.textinput.Prompt = prompt - return i -} - -// CharLimit sets the character limit of the input field. -func (i *Input) CharLimit(charlimit int) *Input { - i.textinput.CharLimit = charlimit - return i -} - -// EchoMode sets the input behavior of the text Input field. -type EchoMode textinput.EchoMode - -const ( - // EchoModeNormal displays text as is. - // This is the default behavior. - EchoModeNormal EchoMode = EchoMode(textinput.EchoNormal) - - // EchoModePassword displays the EchoCharacter mask instead of actual characters. - // This is commonly used for password fields. - EchoModePassword EchoMode = EchoMode(textinput.EchoPassword) - - // EchoModeNone displays nothing as characters are entered. - // This is commonly seen for password fields on the command line. - EchoModeNone EchoMode = EchoMode(textinput.EchoNone) -) - -// EchoMode sets the echo mode of the input. -func (i *Input) EchoMode(mode EchoMode) *Input { - i.textinput.EchoMode = textinput.EchoMode(mode) - return i -} - -// Placeholder sets the placeholder of the text input. -func (i *Input) Placeholder(str string) *Input { - i.textinput.Placeholder = str - return i -} - -// Inline sets whether the title and input should be on the same line. -func (i *Input) Inline(inline bool) *Input { - i.inline = inline - return i -} - -// Validate sets the validation function of the input field. -func (i *Input) Validate(validate func(string) error) *Input { - i.validate = validate - return i -} - -// Error returns the error of the input field. -func (i *Input) Error() error { return i.err } - -// Skip returns whether the input should be skipped or should be blocking. -func (*Input) Skip() bool { return false } - -// Zoom returns whether the input should be zoomed. -func (*Input) Zoom() bool { return false } - -// Focus focuses the input field. -func (i *Input) Focus() tea.Cmd { - i.focused = true - return i.textinput.Focus() -} - -// Blur blurs the input field. -func (i *Input) Blur() tea.Cmd { - i.focused = false - i.accessor.Set(i.textinput.Value()) - i.textinput.Blur() - i.err = i.validate(i.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the input field. -func (i *Input) KeyBinds() []key.Binding { - if i.textinput.ShowSuggestions { - return []key.Binding{i.keymap.AcceptSuggestion, i.keymap.Prev, i.keymap.Submit, i.keymap.Next} - } - return []key.Binding{i.keymap.Prev, i.keymap.Submit, i.keymap.Next} -} - -// Init initializes the input field. -func (i *Input) Init() tea.Cmd { - i.textinput.Blur() - return nil -} - -// Update updates the input field. -func (i *Input) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - i.hasDarkBg = msg.IsDark() - case updateFieldMsg: - return i, nil - case tea.KeyPressMsg: - i.err = nil - - switch { - case key.Matches(msg, i.keymap.Prev): - cmds = append(cmds, PrevField) - case key.Matches(msg, i.keymap.Next, i.keymap.Submit): - value := i.textinput.Value() - i.err = i.validate(value) - if i.err != nil { - return i, nil - } - cmds = append(cmds, NextField) - } - } - - i.textinput, cmd = i.textinput.Update(msg) - cmds = append(cmds, cmd) - i.accessor.Set(i.textinput.Value()) - - return i, tea.Batch(cmds...) -} - -func (i *Input) activeStyles() *FieldStyles { - theme := i.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if i.focused { - return &theme.Theme(i.hasDarkBg).Focused - } - return &theme.Theme(i.hasDarkBg).Blurred -} - -// View renders the input field. -func (i *Input) View() string { - styles := i.activeStyles() - maxWidth := i.width - styles.Base.GetHorizontalFrameSize() - - // NB: since the method is on a pointer receiver these are being mutated. - // Because this runs on every render this shouldn't matter in practice, - // however. - st := i.textinput.Styles() - st.Cursor.Color = styles.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.Text = styles.TextInput.Text - st.Focused.Placeholder = styles.TextInput.Placeholder - i.textinput.SetStyles(st) - - // Adjust text input size to its char limit if it fit in its width - if i.textinput.CharLimit > 0 { - i.textinput.SetWidth(max(min(i.textinput.CharLimit, i.textinput.Width(), maxWidth), 0)) - } - - var sb strings.Builder - if i.title != "" { - sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth))) - if !i.inline { - sb.WriteString("\n") - } - } - if i.description != "" { - sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth))) - if !i.inline { - sb.WriteString("\n") - } - } - sb.WriteString(i.textinput.View()) - - return styles.Base. - Width(i.width). - Height(i.height). - Render(sb.String()) -} - -// RunAccessible runs the input field in accessible mode. -func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { - styles := i.activeStyles() - validator := func(input string) error { - if i.textinput.CharLimit > 0 && len(input) > i.textinput.CharLimit { - return fmt.Errorf("Input cannot exceed %d characters", i.textinput.CharLimit) - } - return i.validate(input) - } - - switch i.textinput.EchoMode { - case textinput.EchoNormal: - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(i.title, "Input:")) - value := promptString(w, r, prompt, i.GetValue().(string), validator) - i.accessor.Set(value) - return nil - default: - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(i.title, "Password:")) - if fd, ok := r.(interface{ Fd() uintptr }); ok { - value, err := promptPassword(w, fd.Fd(), prompt, validator) - if err != nil { - return err - } - i.accessor.Set(value) - return nil - } - return errors.New("password asking needs a tty") - } -} - -// WithKeyMap sets the keymap on an input field. -func (i *Input) WithKeyMap(k *KeyMap) Field { - i.keymap = k.Input - i.textinput.KeyMap.AcceptSuggestion = i.keymap.AcceptSuggestion - return i -} - -// WithTheme sets the theme of the input field. -func (i *Input) WithTheme(theme Theme) Field { - if i.theme != nil { - return i - } - i.theme = theme - return i -} - -// WithWidth sets the width of the input field. -func (i *Input) WithWidth(width int) Field { - styles := i.activeStyles() - i.width = width - frameSize := styles.Base.GetHorizontalFrameSize() - promptWidth := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) - titleWidth := lipgloss.Width(styles.Title.Render(i.title)) - descriptionWidth := lipgloss.Width(styles.Description.Render(i.description)) - i.textinput.SetWidth(width - frameSize - promptWidth - 1) - if i.inline { - i.textinput.SetWidth(i.textinput.Width() - titleWidth - descriptionWidth) - } - return i -} - -// WithHeight sets the height of the input field. -func (i *Input) WithHeight(height int) Field { - i.height = height - return i -} - -// WithPosition sets the position of the input field. -func (i *Input) WithPosition(p FieldPosition) Field { - i.keymap.Prev.SetEnabled(!p.IsFirst()) - i.keymap.Next.SetEnabled(!p.IsLast()) - i.keymap.Submit.SetEnabled(p.IsLast()) - return i -} - -// GetValue returns the value of the field. -func (i *Input) GetValue() any { - return i.accessor.Get() -} diff --git a/zSDKs/sdk-cli/internal/forms/field_select.go b/zSDKs/sdk-cli/internal/forms/field_select.go deleted file mode 100644 index 5bb758b36..000000000 --- a/zSDKs/sdk-cli/internal/forms/field_select.go +++ /dev/null @@ -1,680 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "cmp" - "fmt" - "io" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -const ( - minHeight = 1 - defaultHeight = 10 -) - -// Select is a select field. -// -// A select field is a field that allows the user to select from a list of -// options. The options can be provided statically or dynamically using Options -// or OptionsFunc. The options can be filtered using "/" and navigation is done -// using j/k, up/down, or ctrl+n/ctrl+p keys. -type Select[T comparable] struct { - accessor Accessor[T] - - viewport viewport.Model - - title string - description string - options []Option[T] - filteredOptions []Option[T] - - validate func(T) error - err error - - selected int - focused bool - filtering bool - filter textinput.Model - - inline bool - width int - height int - theme Theme - hasDarkBg bool - keymap SelectKeyMap -} - -// NewSelect creates a new select field. -// -// A select field is a field that allows the user to select from a list of -// options. The options can be provided statically or dynamically using Options -// or OptionsFunc. The options can be filtered using "/" and navigation is done -// using j/k, up/down, or ctrl+n/ctrl+p keys. -func NewSelect[T comparable]() *Select[T] { - filter := textinput.New() - filter.Prompt = "/" - - return &Select[T]{ - accessor: &EmbeddedAccessor[T]{}, - validate: func(T) error { return nil }, - filtering: false, - filter: filter, - } -} - -// Value sets the value of the select field. -func (s *Select[T]) Value(value *T) *Select[T] { - return s.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the select field. -func (s *Select[T]) Accessor(accessor Accessor[T]) *Select[T] { - s.accessor = accessor - s.selectValue(s.accessor.Get()) - s.updateValue() - return s -} - -func (s *Select[T]) selectValue(value T) { - for i, o := range s.options { - if o.Value == value { - s.selected = i - break - } - } -} - -// Title sets the title of the select field. -func (s *Select[T]) Title(title string) *Select[T] { - s.title = title - return s -} - -// Filtering sets the filtering state of the select field. -func (s *Select[T]) Filtering(filtering bool) *Select[T] { - s.filtering = filtering - s.filter.Focus() - return s -} - -// Description sets the description of the select field. -func (s *Select[T]) Description(description string) *Select[T] { - s.description = description - return s -} - -// Options sets the options of the select field. -// -// This is what your user will select from. -// -// Title -// Description -// -// -> Option 1 -// Option 2 -// Option 3 -func (s *Select[T]) Options(options ...Option[T]) *Select[T] { - if len(options) <= 0 { - return s - } - s.options = options - s.filteredOptions = options - - s.selectOption() - - s.updateViewportSize() - s.updateValue() - - return s -} - -func (s *Select[T]) selectOption() { - // Set the cursor to the existing value. - for i, option := range s.options { - if option.Value == s.accessor.Get() { - s.selected = i - break - } - } - s.ensureCursorVisible() -} - -// Inline sets whether the select input should be inline. -func (s *Select[T]) Inline(v bool) *Select[T] { - s.inline = v - if v { - s.Height(1) - } - s.keymap.Left.SetEnabled(v) - s.keymap.Right.SetEnabled(v) - s.keymap.Up.SetEnabled(!v) - s.keymap.Down.SetEnabled(!v) - return s -} - -// Height sets the height of the select field. If the number of options exceeds -// the height, the select field will become scrollable. -func (s *Select[T]) Height(height int) *Select[T] { - s.height = height - s.updateViewportSize() - return s -} - -// Validate sets the validation function of the select field. -func (s *Select[T]) Validate(validate func(T) error) *Select[T] { - s.validate = validate - return s -} - -// Error returns the error of the select field. -func (s *Select[T]) Error() error { return s.err } - -// Skip returns whether the select should be skipped or should be blocking. -func (*Select[T]) Skip() bool { return false } - -// Zoom returns whether the input should be zoomed. -func (*Select[T]) Zoom() bool { return false } - -// Focus focuses the select field. -func (s *Select[T]) Focus() tea.Cmd { - s.focused = true - return nil -} - -// Blur blurs the select field. -func (s *Select[T]) Blur() tea.Cmd { - value := s.accessor.Get() - if s.inline { - s.clearFilter() - s.selectValue(value) - } - s.focused = false - s.err = s.validate(value) - return nil -} - -// Hovered returns the value of the option under the cursor, and a bool -// indicating whether one was found. If there are no visible options, returns -// a zero-valued T and false. -func (s *Select[T]) Hovered() (T, bool) { - if len(s.filteredOptions) == 0 || s.selected >= len(s.filteredOptions) { - var zero T - return zero, false - } - return s.filteredOptions[s.selected].Value, true -} - -// KeyBinds returns the help keybindings for the select field. -func (s *Select[T]) KeyBinds() []key.Binding { - return []key.Binding{ - s.keymap.Up, - s.keymap.Down, - s.keymap.Left, - s.keymap.Right, - s.keymap.Filter, - s.keymap.SetFilter, - s.keymap.ClearFilter, - s.keymap.Prev, - s.keymap.Next, - s.keymap.Submit, - } -} - -// Init initializes the select field. -func (s *Select[T]) Init() tea.Cmd { - return nil -} - -// Update updates the select field. -func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) { - s.updateViewportSize() - - var cmd tea.Cmd - if s.filtering { - s.filter, cmd = s.filter.Update(msg) - } - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - s.hasDarkBg = msg.IsDark() - case updateFieldMsg: - return s, nil - - case tea.KeyPressMsg: - s.err = nil - switch { - case key.Matches(msg, s.keymap.Filter): - s.setFiltering(true) - return s, s.filter.Focus() - case key.Matches(msg, s.keymap.SetFilter): - if len(s.filteredOptions) <= 0 { - s.filter.SetValue("") - s.filteredOptions = s.options - } - s.setFiltering(false) - case key.Matches(msg, s.keymap.ClearFilter): - s.clearFilter() - case key.Matches(msg, s.keymap.Up, s.keymap.Left): - // When filtering we should ignore j/k keybindings - // - // XXX: Currently, the below check doesn't account for keymap - // changes. When making this fix it's worth considering ignoring - // whether to ignore all up/down keybindings as ignoring a-zA-Z0-9 - // may not be enough when international keyboards are considered. - if s.filtering && (msg.String() == "k" || msg.String() == "h") { - break - } - s.selected = s.selected - 1 - if s.selected < 0 { - s.selected = len(s.filteredOptions) - 1 - s.viewport.GotoBottom() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msg, s.keymap.GotoTop): - if s.filtering { - break - } - s.selected = 0 - s.viewport.GotoTop() - s.updateValue() - case key.Matches(msg, s.keymap.GotoBottom): - if s.filtering { - break - } - s.selected = len(s.filteredOptions) - 1 - s.viewport.GotoBottom() - case key.Matches(msg, s.keymap.HalfPageUp): - s.selected = max(s.selected-s.viewport.Height()/2, 0) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msg, s.keymap.HalfPageDown): - s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msg, s.keymap.Down, s.keymap.Right): - // When filtering we should ignore j/k keybindings - // - // XXX: See note in the previous case match. - if s.filtering && (msg.String() == "j" || msg.String() == "l") { - break - } - s.selected = s.selected + 1 - if s.selected > len(s.filteredOptions)-1 { - s.selected = 0 - s.viewport.GotoTop() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msg, s.keymap.Prev): - if s.selected >= len(s.filteredOptions) { - break - } - s.updateValue() - s.err = s.validate(s.accessor.Get()) - if s.err != nil { - return s, nil - } - s.updateValue() - return s, PrevField - case key.Matches(msg, s.keymap.Next, s.keymap.Submit): - if s.selected >= len(s.filteredOptions) { - break - } - s.setFiltering(false) - s.updateValue() - s.err = s.validate(s.accessor.Get()) - if s.err != nil { - return s, nil - } - s.updateValue() - return s, NextField - } - - if s.filtering { - s.filteredOptions = s.options - if s.filter.Value() != "" { - s.filteredOptions = nil - for _, option := range s.options { - if s.filterFunc(option.Key) { - s.filteredOptions = append(s.filteredOptions, option) - } - } - } - if len(s.filteredOptions) > 0 { - s.selected = min(s.selected, len(s.filteredOptions)-1) - } - } - - s.ensureCursorVisible() - } - - return s, cmd -} - -func (s *Select[T]) updateValue() { - if s.selected < len(s.filteredOptions) && s.selected >= 0 { - s.accessor.Set(s.filteredOptions[s.selected].Value) - } -} - -// updateViewportSize updates the viewport size according to the Height setting -// on this select field. -func (s *Select[T]) updateViewportSize() { - if s.height > 0 { - yoffset := 0 - if ss := s.titleView(); ss != "" { - yoffset += lipgloss.Height(ss) - } - if ss := s.descriptionView(); ss != "" { - yoffset += lipgloss.Height(ss) - } - s.viewport.SetHeight(max(minHeight, s.height-yoffset)) - s.ensureCursorVisible() - } else { - // If no height is set size the viewport to the number of options. - v, _, _ := s.optionsView() - s.viewport.SetHeight(lipgloss.Height(v)) - } - if s.width > 0 { - s.viewport.SetWidth(s.width) - } else { - v, _, _ := s.optionsView() - s.viewport.SetWidth(lipgloss.Width(v)) - } -} - -func (s *Select[T]) activeStyles() *FieldStyles { - theme := s.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if s.focused { - return &theme.Theme(s.hasDarkBg).Focused - } - return &theme.Theme(s.hasDarkBg).Blurred -} - -func (s *Select[T]) titleView() string { - var ( - styles = s.activeStyles() - sb = strings.Builder{} - maxWidth = s.width - styles.Base.GetHorizontalFrameSize() - ) - if s.filtering { - sb.WriteString(s.filter.View()) - } else if s.filter.Value() != "" && !s.inline { - sb.WriteString(styles.Description.Render("/" + s.filter.Value())) - } else { - sb.WriteString(styles.Title.Render(wrap(s.title, maxWidth))) - } - if s.err != nil { - sb.WriteString(styles.ErrorIndicator.String()) - } - return sb.String() -} - -func (s *Select[T]) descriptionView() string { - if s.description == "" { - return "" - } - maxWidth := s.width - s.activeStyles().Base.GetHorizontalFrameSize() - return s.activeStyles().Description.Render(wrap(s.description, maxWidth)) -} - -func (s *Select[T]) optionsView() (string, int, int) { - var ( - styles = s.activeStyles() - sb strings.Builder - ) - - if s.inline { - option := styles.TextInput.Placeholder.Render("No matches") - if len(s.filteredOptions) > 0 { - option = styles.SelectedOption.Render(s.filteredOptions[s.selected].Key) - } - return lipgloss.NewStyle(). - Width(s.width). - Render(lipgloss.JoinHorizontal( - lipgloss.Left, - styles.PrevIndicator.Faint(s.selected <= 0).String(), - option, - styles.NextIndicator.Faint(s.selected == len(s.filteredOptions)-1).String(), - )), - -1, 1 - } - - var cursorOffset int - var cursorHeight int - for i, option := range s.filteredOptions { - selected := s.selected == i - line := s.renderOption(option, selected) - if i < s.selected { - cursorOffset += lipgloss.Height(line) - } - if selected { - cursorHeight = lipgloss.Height(line) - } - - sb.WriteString(line) - if i < len(s.options)-1 { - sb.WriteString("\n") - } - } - - for i := len(s.filteredOptions); i < len(s.options)-1; i++ { - sb.WriteString("\n") - } - - return sb.String(), cursorOffset, cursorHeight -} - -// cursorLineOffset computes the line offset and height (in lines) for the -// currently selected option without rendering the full options string. -func (s *Select[T]) cursorLineOffset() (offset int, height int) { - for i, option := range s.filteredOptions { - line := s.renderOption(option, s.selected == i) - h := lipgloss.Height(line) - if i < s.selected { - offset += h - } - if i == s.selected { - height = h - return offset, height - } - } - return offset, height -} - -// ensureVisible scrolls a viewport the minimum amount so that the region -// [offset, offset+height) is within the visible area. -func ensureVisible(vp *viewport.Model, offset, height int) { - if height <= 0 { - return - } - yOff := vp.YOffset() - vHeight := vp.Height() - if offset < yOff { - vp.ScrollUp(yOff - offset) - } else if offset+height > yOff+vHeight { - vp.ScrollDown(offset + height - yOff - vHeight) - } -} - -func (s *Select[T]) ensureCursorVisible() { - offset, height := s.cursorLineOffset() - ensureVisible(&s.viewport, offset, height) -} - -func (s *Select[T]) renderOption(option Option[T], selected bool) string { - var ( - styles = s.activeStyles() - cursor = styles.SelectSelector.String() - cursorW = lipgloss.Width(cursor) - maxWidth = s.width - s.activeStyles().Base.GetHorizontalFrameSize() - cursorW - ) - - key := wrap(option.Key, maxWidth) - - if selected { - return lipgloss.JoinHorizontal( - lipgloss.Left, - cursor, - styles.SelectedOption.Render(key), - ) - } - return lipgloss.JoinHorizontal( - lipgloss.Left, - strings.Repeat(" ", cursorW), - styles.UnselectedOption.Render(key), - ) -} - -// View renders the select field. -func (s *Select[T]) View() string { - styles := s.activeStyles() - vpc, _, _ := s.optionsView() - s.viewport.SetContent(vpc) - - var parts []string - if s.title != "" { - parts = append(parts, s.titleView()) - } - if s.description != "" { - parts = append(parts, s.descriptionView()) - } - parts = append(parts, s.viewport.View()) - return styles.Base.Width(s.width).Height(s.height). - Render(strings.Join(parts, "\n")) -} - -// clearFilter clears the value of the filter. -func (s *Select[T]) clearFilter() { - s.filter.SetValue("") - s.filteredOptions = s.options - s.setFiltering(false) -} - -// setFiltering sets the filter of the select field. -func (s *Select[T]) setFiltering(filtering bool) { - if s.inline && filtering { - s.filter.SetWidth(lipgloss.Width(s.titleView()) - 1 - 1) - } - s.filtering = filtering - s.keymap.SetFilter.SetEnabled(filtering) - s.keymap.Filter.SetEnabled(!filtering) - s.keymap.ClearFilter.SetEnabled(!filtering && s.filter.Value() != "") -} - -// filterFunc returns true if the option matches the filter. -func (s *Select[T]) filterFunc(option string) bool { - // XXX: remove diacritics or allow customization of filter function. - return strings.Contains(strings.ToLower(option), strings.ToLower(s.filter.Value())) -} - -// RunAccessible runs an accessible select field. -func (s *Select[T]) RunAccessible(w io.Writer, r io.Reader) error { - styles := s.activeStyles() - _, _ = fmt.Fprintln(w, styles.Title. - PaddingRight(1). - Render(cmp.Or(s.title, "Select:"))) - - for i, option := range s.options { - _, _ = fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) - } - - var defaultValue *int - switch s.accessor.(type) { - case *PointerAccessor[T]: // if its of this type, it means it has a default value - s.selectOption() // make sure s.selected is set - idx := s.selected + 1 - defaultValue = &idx - } - prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) - if len(s.options) == 1 { - prompt = "There is only one option available; enter the number 1:" - } - for { - choice := promptInt(w, r, prompt, 1, len(s.options), defaultValue) - option := s.options[choice-1] - if err := s.validate(option.Value); err != nil { - _, _ = fmt.Fprintln(w, err.Error()) - _, _ = fmt.Fprintln(w) - continue - } - s.accessor.Set(option.Value) - return nil - } -} - -// WithTheme sets the theme of the select field. -func (s *Select[T]) WithTheme(theme Theme) Field { - if s.theme != nil { - return s - } - s.theme = theme - styles := s.theme.Theme(s.hasDarkBg) - - st := s.filter.Styles() - st.Cursor.Color = styles.Focused.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.Focused.TextInput.Prompt - st.Focused.Text = styles.Focused.TextInput.Text - st.Focused.Placeholder = styles.Focused.TextInput.Placeholder - s.filter.SetStyles(st) - - s.updateViewportSize() - return s -} - -// WithKeyMap sets the keymap on a select field. -func (s *Select[T]) WithKeyMap(k *KeyMap) Field { - s.keymap = k.Select - s.keymap.Left.SetEnabled(s.inline) - s.keymap.Right.SetEnabled(s.inline) - s.keymap.Up.SetEnabled(!s.inline) - s.keymap.Down.SetEnabled(!s.inline) - return s -} - -// WithWidth sets the width of the select field. -func (s *Select[T]) WithWidth(width int) Field { - s.width = width - s.updateViewportSize() - return s -} - -// WithHeight sets the height of the select field. -func (s *Select[T]) WithHeight(height int) Field { - return s.Height(height) -} - -// WithPosition sets the position of the select field. -func (s *Select[T]) WithPosition(p FieldPosition) Field { - if s.filtering { - return s - } - s.keymap.Prev.SetEnabled(!p.IsFirst()) - s.keymap.Next.SetEnabled(!p.IsLast()) - s.keymap.Submit.SetEnabled(p.IsLast()) - return s -} - -// GetValue returns the value of the field. -func (s *Select[T]) GetValue() any { - return s.accessor.Get() -} - -// GetFiltering returns the filtering state of the field. -func (s *Select[T]) GetFiltering() bool { - return s.filtering -} diff --git a/zSDKs/sdk-cli/internal/forms/field_text.go b/zSDKs/sdk-cli/internal/forms/field_text.go deleted file mode 100644 index c01c6b8c9..000000000 --- a/zSDKs/sdk-cli/internal/forms/field_text.go +++ /dev/null @@ -1,386 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "cmp" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - - "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textarea" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Text is a text field. -// -// A text box is responsible for getting multi-line input from the user. Use -// it to gather longer-form user input. The Text field can be filled with an -// EDITOR. -type Text struct { - accessor Accessor[string] - - title string - description string - - externalEditor bool - editorCmd string - editorArgs []string - editorExtension string - - textarea textarea.Model - - focused bool - validate func(string) error - err error - - width int - - theme Theme - hasDarkBg bool - keymap TextKeyMap -} - -// NewText creates a new text field. -// -// A text box is responsible for getting multi-line input from the user. Use -// it to gather longer-form user input. The Text field can be filled with an -// EDITOR. -func NewText() *Text { - text := textarea.New() - text.ShowLineNumbers = false - text.Prompt = "" - st := text.Styles() - st.Focused.CursorLine = lipgloss.NewStyle() - text.SetStyles(st) - - editorCmd, editorArgs := getEditor() - - t := &Text{ - accessor: &EmbeddedAccessor[string]{}, - textarea: text, - validate: func(string) error { return nil }, - externalEditor: true, - editorCmd: editorCmd, - editorArgs: editorArgs, - editorExtension: "md", - } - - return t -} - -// Value sets the value of the text field. -func (t *Text) Value(value *string) *Text { - return t.Accessor(NewPointerAccessor(value)) -} - -// Accessor sets the accessor of the text field. -func (t *Text) Accessor(accessor Accessor[string]) *Text { - t.accessor = accessor - t.textarea.SetValue(t.accessor.Get()) - return t -} - -// Title sets the text field's title. -func (t *Text) Title(title string) *Text { - t.title = title - return t -} - -// Description sets the description of the text field. -func (t *Text) Description(description string) *Text { - t.description = description - return t -} - -// Lines sets the number of lines to show of the text field. -func (t *Text) Lines(lines int) *Text { - t.textarea.SetHeight(lines) - return t -} - -// CharLimit sets the character limit of the text field. -func (t *Text) CharLimit(charlimit int) *Text { - t.textarea.CharLimit = charlimit - return t -} - -// ShowLineNumbers sets whether or not to show line numbers. -func (t *Text) ShowLineNumbers(show bool) *Text { - t.textarea.ShowLineNumbers = show - return t -} - -// Placeholder sets the placeholder of the text field. -// -// This placeholder will be static, for dynamic placeholders use `PlaceholderFunc`. -func (t *Text) Placeholder(str string) *Text { - t.textarea.Placeholder = str - return t -} - -// Validate sets the validation function of the text field. -func (t *Text) Validate(validate func(string) error) *Text { - t.validate = validate - return t -} - -// ExternalEditor sets whether option to launch an editor is available. -func (t *Text) ExternalEditor(enabled bool) *Text { - t.externalEditor = enabled - return t -} - -const defaultEditor = "nano" - -// getEditor returns the editor command and arguments. -func getEditor() (string, []string) { - editor := strings.Fields(os.Getenv("EDITOR")) - if len(editor) > 0 { - return editor[0], editor[1:] - } - return defaultEditor, nil -} - -// Editor specifies which editor to use. -// -// The first argument provided is used as the editor command (vim, nvim, nano, etc...) -// The following (optional) arguments provided are passed as arguments to the editor command. -func (t *Text) Editor(editor ...string) *Text { - if len(editor) > 0 { - t.editorCmd = editor[0] - } - if len(editor) > 1 { - t.editorArgs = editor[1:] - } - return t -} - -// EditorExtension specifies arguments to pass into the editor. -func (t *Text) EditorExtension(extension string) *Text { - t.editorExtension = extension - return t -} - -// Error returns the error of the text field. -func (t *Text) Error() error { return t.err } - -// Skip returns whether the textarea should be skipped or should be blocking. -func (*Text) Skip() bool { return false } - -// Zoom returns whether the note should be zoomed. -func (*Text) Zoom() bool { return false } - -// Focus focuses the text field. -func (t *Text) Focus() tea.Cmd { - t.focused = true - return t.textarea.Focus() -} - -// Blur blurs the text field. -func (t *Text) Blur() tea.Cmd { - t.focused = false - t.accessor.Set(t.textarea.Value()) - t.textarea.Blur() - t.err = t.validate(t.accessor.Get()) - return nil -} - -// KeyBinds returns the help message for the text field. -func (t *Text) KeyBinds() []key.Binding { - t.keymap.Editor.SetEnabled(t.externalEditor) - return []key.Binding{t.keymap.NewLine, t.keymap.Editor, t.keymap.Prev, t.keymap.Submit, t.keymap.Next} -} - -type updateValueMsg []byte - -// Init initializes the text field. -func (t *Text) Init() tea.Cmd { - t.textarea.Blur() - return nil -} - -// Update updates the text field. -func (t *Text) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - t.hasDarkBg = msg.IsDark() - case updateValueMsg: - t.textarea.SetValue(string(msg)) - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - t.accessor.Set(t.textarea.Value()) - case updateFieldMsg: - return t, nil - case tea.KeyPressMsg: - t.err = nil - - switch { - case key.Matches(msg, t.keymap.Editor): - ext := strings.TrimPrefix(t.editorExtension, ".") - tmpFile, _ := os.CreateTemp(os.TempDir(), "*."+ext) - cmd := exec.CommandContext( - context.TODO(), - t.editorCmd, - append(t.editorArgs, tmpFile.Name())..., - ) - _ = os.WriteFile(tmpFile.Name(), []byte(t.textarea.Value()), 0o644) - cmds = append(cmds, tea.ExecProcess(cmd, func(error) tea.Msg { - content, _ := os.ReadFile(tmpFile.Name()) - _ = os.Remove(tmpFile.Name()) - return updateValueMsg(content) - })) - case key.Matches(msg, t.keymap.Next, t.keymap.Submit): - value := t.textarea.Value() - t.err = t.validate(value) - if t.err != nil { - return t, nil - } - cmds = append(cmds, NextField) - case key.Matches(msg, t.keymap.Prev): - value := t.textarea.Value() - t.err = t.validate(value) - if t.err != nil { - return t, nil - } - cmds = append(cmds, PrevField) - } - } - - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - t.accessor.Set(t.textarea.Value()) - - return t, tea.Batch(cmds...) -} - -func (t *Text) activeStyles() *FieldStyles { - theme := t.theme - if theme == nil { - theme = ThemeFunc(themeCharm) - } - if t.focused { - return &theme.Theme(t.hasDarkBg).Focused - } - return &theme.Theme(t.hasDarkBg).Blurred -} - -// View renders the text field. -func (t *Text) View() string { - styles := t.activeStyles() - st := t.textarea.Styles() - - if t.focused { - st.Focused.Placeholder = styles.TextInput.Placeholder - st.Focused.Text = styles.TextInput.Text - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.CursorLine = styles.TextInput.Text - } else { - st.Blurred.Placeholder = styles.TextInput.Placeholder - st.Blurred.Text = styles.TextInput.Text - st.Blurred.Prompt = styles.TextInput.Prompt - st.Blurred.CursorLine = styles.TextInput.Text - } - st.Cursor.Color = styles.TextInput.Cursor.GetBackground() - t.textarea.SetStyles(st) - - maxWidth := t.width - styles.Base.GetHorizontalFrameSize() - var parts []string - if t.title != "" { - header := styles.Title.Render(wrap(t.title, maxWidth)) - if t.err != nil { - header += styles.ErrorIndicator.String() - } - parts = append(parts, header) - } - if t.description != "" { - parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) - } - parts = append(parts, t.textarea.View()) - - return styles.Base. - Render(strings.Join(parts, "\n")) -} - -// RunAccessible runs an accessible text field. -func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { - styles := t.activeStyles() - prompt := styles.Title. - PaddingRight(1). - Render(cmp.Or(t.title, "Input:")) - t.accessor.Set(promptString( - w, - r, - prompt, - t.GetValue().(string), - func(input string) error { - if err := t.validate(input); err != nil { - // Handle the error from t.validate, return it - return err - } - - if t.textarea.CharLimit > 0 && len(input) > t.textarea.CharLimit { - return fmt.Errorf("Input cannot exceed %d characters", t.textarea.CharLimit) - } - return nil - }, - )) - return nil -} - -// WithTheme sets the theme on a text field. -func (t *Text) WithTheme(theme Theme) Field { - if t.theme != nil { - return t - } - t.theme = theme - return t -} - -// WithKeyMap sets the keymap on a text field. -func (t *Text) WithKeyMap(k *KeyMap) Field { - t.keymap = k.Text - t.textarea.KeyMap.InsertNewline.SetKeys(t.keymap.NewLine.Keys()...) - return t -} - -// WithWidth sets the width of the text field. -func (t *Text) WithWidth(width int) Field { - t.width = width - t.textarea.SetWidth(width - t.activeStyles().Base.GetHorizontalFrameSize()) - return t -} - -// WithHeight sets the height of the text field. -func (t *Text) WithHeight(height int) Field { - adjust := 0 - if t.title != "" { - adjust++ - } - if t.description != "" { - adjust++ - } - t.textarea.SetHeight(height - t.activeStyles().Base.GetVerticalFrameSize() - adjust) - return t -} - -// WithPosition sets the position information of the text field. -func (t *Text) WithPosition(p FieldPosition) Field { - t.keymap.Prev.SetEnabled(!p.IsFirst()) - t.keymap.Next.SetEnabled(!p.IsLast()) - t.keymap.Submit.SetEnabled(p.IsLast()) - return t -} - -// GetValue returns the value of the field. -func (t *Text) GetValue() any { - return t.accessor.Get() -} diff --git a/zSDKs/sdk-cli/internal/forms/form.go b/zSDKs/sdk-cli/internal/forms/form.go deleted file mode 100644 index 891d59961..000000000 --- a/zSDKs/sdk-cli/internal/forms/form.go +++ /dev/null @@ -1,585 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "cmp" - "errors" - "fmt" - "io" - "os" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -const defaultWidth = 80 - -// Model is a Bubble Tea model whose Update returns the concrete field, group -// or form type rather than [tea.Model]. -type Model interface { - Init() tea.Cmd - Update(msg tea.Msg) (Model, tea.Cmd) - View() string -} - -// viewModel adapts a [Model] to [tea.Model]. -type viewModel struct { - Model - viewHook func(tea.View) tea.View -} - -// Update implements [tea.Model]. -func (w viewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m, cmd := w.Model.Update(msg) - return viewModel{ - Model: m, - viewHook: w.viewHook, - }, cmd -} - -// View implements [tea.Model]. -func (w viewModel) View() tea.View { - var view tea.View - if w.viewHook != nil { - view = w.viewHook(view) - } - view.SetContent(w.Model.View()) - return view -} - -// FormState represents the current state of the form. -type FormState int - -const ( - // StateNormal is when the user is completing the form. - StateNormal FormState = iota - - // StateCompleted is when the user has completed the form. - StateCompleted - - // StateAborted is when the user has aborted the form. - StateAborted -) - -// ErrUserAborted is the error returned when a user exits the form before submitting. -var ErrUserAborted = errors.New("user aborted") - -// Form is a collection of groups that are displayed one at a time on a "page". -// -// The form can navigate between groups and is complete once all the groups are -// complete. -type Form struct { - // collection of groups - selector *selector[*Group] - - // callbacks - SubmitCmd tea.Cmd - CancelCmd tea.Cmd - - State FormState - - // whether or not to use bubble tea rendering for accessibility - // purposes, if true, the form will render with basic prompting primitives - // to be more accessible to screen readers. - accessible bool - - quitting bool - aborted bool - - // options - width int - height int - theme Theme - hasDarkBg bool - keymap *KeyMap - teaOptions []tea.ProgramOption - - // accessible mode IO - output io.Writer - input io.Reader -} - -// NewForm returns a form with the given groups and default themes and -// keybindings. -// -// Use With* methods to customize the form with options, such as setting -// different themes and keybindings. -func NewForm(groups ...*Group) *Form { - selector := newSelector(groups) - - f := &Form{ - selector: selector, - keymap: NewDefaultKeyMap(), - teaOptions: []tea.ProgramOption{ - tea.WithOutput(os.Stderr), - }, - } - - // NB: If dynamic forms come into play this will need to be applied when - // groups and fields are added. - f.WithKeyMap(f.keymap) - f.WithWidth(f.width) - f.WithHeight(f.height) - f.UpdateFieldPositions() - - if os.Getenv("TERM") == "dumb" { - f.WithWidth(defaultWidth) - f.WithAccessible(true) - } - - return f -} - -// Field is a primitive of a form. -// -// A field represents a single input control on a form such as a text input, -// confirm button, select option, etc... -// -// Each field implements the Bubble Tea Model interface. -type Field interface { - // Bubble Tea Model - Model - - // Bubble Tea Events - Blur() tea.Cmd - Focus() tea.Cmd - - // Errors and Validation - Error() error - - // RunAccessible runs the field in accessible mode with the given IO. - RunAccessible(w io.Writer, r io.Reader) error - - // Skip returns whether this input should be skipped or not. - Skip() bool - - // Zoom returns whether this input should be zoomed or not. - // Zoom allows the field to take focus of the group / form height. - Zoom() bool - - // KeyBinds returns help keybindings. - KeyBinds() []key.Binding - - // WithTheme sets the theme on a field. - WithTheme(Theme) Field - - // WithKeyMap sets the keymap on a field. - WithKeyMap(*KeyMap) Field - - // WithWidth sets the width of a field. - WithWidth(int) Field - - // WithHeight sets the height of a field. - WithHeight(int) Field - - // WithPosition tells the field the index of the group and position it is in. - WithPosition(FieldPosition) Field - - // GetValue returns the field's value. - GetValue() any -} - -// FieldPosition is positional information about the given field and form. -type FieldPosition struct { - Group int - Field int - FirstField int - LastField int - GroupCount int - FirstGroup int - LastGroup int -} - -// IsFirst returns whether a field is the form's first field. -func (p FieldPosition) IsFirst() bool { - return p.Field == p.FirstField && p.Group == p.FirstGroup -} - -// IsLast returns whether a field is the form's last field. -func (p FieldPosition) IsLast() bool { - return p.Field == p.LastField && p.Group == p.LastGroup -} - -// nextGroupMsg is a message to move to the next group. -type nextGroupMsg struct{} - -// prevGroupMsg is a message to move to the previous group. -type prevGroupMsg struct{} - -// nextGroup is the command to move to the next group. -func nextGroup() tea.Msg { - return nextGroupMsg{} -} - -// prevGroup is the command to move to the previous group. -func prevGroup() tea.Msg { - return prevGroupMsg{} -} - -// WithAccessible sets the form to run in accessible mode to avoid redrawing the -// views which makes it easier for screen readers to read and describe the form. -// -// This avoids using the Bubble Tea renderer and instead simply uses basic -// terminal prompting to gather input which degrades the user experience but -// provides accessibility. -func (f *Form) WithAccessible(accessible bool) *Form { - f.accessible = accessible - return f -} - -// WithShowHelp sets whether or not the form should show help. -// -// This allows the form groups and field to show what keybindings are available -// to the user. -func (f *Form) WithShowHelp(v bool) *Form { - f.selector.Range(func(_ int, group *Group) bool { - group.WithShowHelp(v) - return true - }) - return f -} - -// WithTheme sets the theme on a form. -// -// This allows all groups and fields to be themed consistently, however themes -// can be applied to each group and field individually for more granular -// control. -func (f *Form) WithTheme(theme Theme) *Form { - if theme == nil { - return f - } - f.theme = theme - f.selector.Range(func(_ int, group *Group) bool { - group.WithTheme(theme) - return true - }) - return f -} - -// WithKeyMap sets the keymap on a form. -// -// This allows customization of the form key bindings. -func (f *Form) WithKeyMap(keymap *KeyMap) *Form { - if keymap == nil { - return f - } - f.keymap = keymap - f.selector.Range(func(_ int, group *Group) bool { - group.WithKeyMap(keymap) - return true - }) - f.UpdateFieldPositions() - return f -} - -// WithWidth sets the width of a form. -// -// This allows all groups and fields to be sized consistently, however width -// can be applied to each group and field individually for more granular -// control. -func (f *Form) WithWidth(width int) *Form { - if width <= 0 { - return f - } - f.width = width - f.selector.Range(func(_ int, group *Group) bool { - group.WithWidth(width) - return true - }) - return f -} - -// WithHeight sets the height of a form. -func (f *Form) WithHeight(height int) *Form { - if height <= 0 { - return f - } - f.height = height - f.selector.Range(func(_ int, group *Group) bool { - group.WithHeight(height) - return true - }) - return f -} - -// WithOutput sets the io.Writer to output the form. -// Default is STDOUT when [Form] is accessible (set with [Form.WithAccessible], STDERR otherwise. -func (f *Form) WithOutput(w io.Writer) *Form { - f.output = w - f.teaOptions = append(f.teaOptions, tea.WithOutput(w)) - return f -} - -// WithInput sets the io.Reader to the input form. -// Default is STDIN. -func (f *Form) WithInput(r io.Reader) *Form { - f.input = r - f.teaOptions = append(f.teaOptions, tea.WithInput(r)) - return f -} - -// UpdateFieldPositions sets the position on all the fields. -func (f *Form) UpdateFieldPositions() *Form { - firstGroup := 0 - lastGroup := f.selector.Total() - 1 - - // determine the first non-hidden group. - f.selector.Range(func(_ int, g *Group) bool { - if !f.isGroupHidden(g) { - return false - } - firstGroup++ - return true - }) - - // determine the last non-hidden group. - f.selector.ReverseRange(func(_ int, g *Group) bool { - if !f.isGroupHidden(g) { - return false - } - lastGroup-- - return true - }) - - f.selector.Range(func(g int, group *Group) bool { - // determine the first non-skippable field. - var firstField int - group.selector.Range(func(_ int, field Field) bool { - if !field.Skip() || group.selector.Total() == 1 { - return false - } - firstField++ - return true - }) - - // determine the last non-skippable field. - var lastField int - group.selector.ReverseRange(func(i int, field Field) bool { - lastField = i - if !field.Skip() || group.selector.Total() == 1 { - return false - } - return true - }) - - group.selector.Range(func(i int, field Field) bool { - field.WithPosition(FieldPosition{ - Group: g, - Field: i, - FirstField: firstField, - LastField: lastField, - FirstGroup: firstGroup, - LastGroup: lastGroup, - }) - return true - }) - - return true - }) - return f -} - -// Init initializes the form. -func (f *Form) Init() tea.Cmd { - var cmds []tea.Cmd - f.selector.Range(func(i int, group *Group) bool { - if i == 0 { - group.active = true - } - cmds = append(cmds, group.Init()) - return true - }) - - if f.isGroupHidden(f.selector.Selected()) { - cmds = append(cmds, nextGroup) - } - - cmds = append(cmds, tea.RequestWindowSize) - return tea.Sequence(cmds...) -} - -// Update updates the form. -func (f *Form) Update(msg tea.Msg) (Model, tea.Cmd) { - // If the form is aborted or completed there's no need to update it. - if f.State != StateNormal { - return f, nil - } - - group := f.selector.Selected() - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - f.hasDarkBg = msg.IsDark() - case tea.WindowSizeMsg: - if f.width == 0 { - f.selector.Range(func(_ int, group *Group) bool { - group.WithWidth(msg.Width) - return true - }) - } - if f.height == 0 { - // calculate the needed height, which is the height of the - // heightest group, accounting for the width, wraps, etc. - neededHeight := 0 - f.selector.Range(func(_ int, group *Group) bool { - neededHeight = max(neededHeight, group.rawHeight()) - return true - }) - - f.selector.Range(func(_ int, group *Group) bool { - group.WithHeight(min(neededHeight, msg.Height)) - return true - }) - } - - case tea.KeyPressMsg: - switch { - case key.Matches(msg, f.keymap.Quit): - f.aborted = true - f.quitting = true - f.State = StateAborted - return f, f.CancelCmd - } - - case nextGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - - submit := func() (Model, tea.Cmd) { - f.quitting = true - f.State = StateCompleted - return f, f.SubmitCmd - } - - if f.selector.OnLast() { - return submit() - } - - for i := f.selector.Index() + 1; i < f.selector.Total(); i++ { - if !f.isGroupHidden(f.selector.Get(i)) { - f.selector.SetIndex(i) - break - } - // all subsequent groups are hidden, so we must act as - // if we were in the last one. - if i == f.selector.Total()-1 { - return submit() - } - } - f.selector.Selected().active = true - return f, f.selector.Selected().Init() - - case prevGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - - for i := f.selector.Index() - 1; i >= 0; i-- { - if !f.isGroupHidden(f.selector.Get(i)) { - f.selector.SetIndex(i) - break - } - } - - f.selector.Selected().active = true - return f, f.selector.Selected().Init() - } - - m, cmd := group.Update(msg) - f.selector.Set(f.selector.Index(), m.(*Group)) - - // A user input a key, this could hide or show other groups, - // let's update all of their positions. - switch msg.(type) { - case tea.KeyPressMsg: - f.UpdateFieldPositions() - } - - return f, cmd -} - -func (f *Form) isGroupHidden(group *Group) bool { - hide := group.hide - if hide == nil { - return false - } - return hide() -} - -func (f *Form) getTheme() *Styles { - if f.theme != nil { - return f.theme.Theme(f.hasDarkBg) - } - return themeCharm(f.hasDarkBg) -} - -func (f *Form) styles() FormStyles { - return f.getTheme().Form -} - -// View renders the form. -func (f *Form) View() string { - if f.quitting { - return "" - } - - return f.styles().Base.Render(f.selector.Selected().View()) -} - -// Run runs the form. -func (f *Form) Run() error { - f.SubmitCmd = tea.Quit - f.CancelCmd = tea.Interrupt - - if f.selector.Total() == 0 { - return nil - } - - if f.accessible { - return f.runAccessible( - cmp.Or[io.Writer](f.output, os.Stdout), - cmp.Or[io.Reader](f.input, os.Stdin), - ) - } - - return f.run() -} - -// run runs the form in normal mode. -func (f *Form) run() error { - m, err := tea.NewProgram( - viewModel{ - Model: f, - viewHook: func(v tea.View) tea.View { - v.ReportFocus = true - return v - }, - }, - f.teaOptions..., - ).Run() - if m.(viewModel).Model.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { - return ErrUserAborted - } - if err != nil { - return fmt.Errorf("form: %w", err) - } - return nil -} - -// runAccessible runs the form in accessible mode. -func (f *Form) runAccessible(w io.Writer, r io.Reader) error { - f.selector.Range(func(_ int, group *Group) bool { - group.selector.Range(func(_ int, field Field) bool { - field.Init() - field.Focus() - _ = field.RunAccessible(w, r) - _, _ = fmt.Fprintln(w) - return true - }) - return true - }) - - return nil -} diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go new file mode 100644 index 000000000..9c7a09282 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -0,0 +1,1278 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +// MIT License +// +// Copyright (c) 2023–2026 Charm +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Package forms renders the terminal forms used by guided prompts: text +// inputs, multi-line text, selects, and confirmations, with an accessible +// line-prompt mode for screen readers and non-TTY input. +// +// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), +// reduced to the fields and form options this CLI uses. +package forms + +import ( + "bufio" + "cmp" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +// ErrUserAborted is returned by Run when the user cancels the form. +var ErrUserAborted = errors.New("user aborted") + +// Field is one input control on a form. +type Field interface { + Init() tea.Cmd + Update(msg tea.Msg) (Field, tea.Cmd) + View() string + Focus() tea.Cmd + Blur() tea.Cmd + Error() error + RunAccessible(w io.Writer, r io.Reader) error + + setWidth(width int) + setHeight(height int) + setPosition(first, last bool) +} + +type nextFieldMsg struct{} + +type prevFieldMsg struct{} + +type nextGroupMsg struct{} + +type prevGroupMsg struct{} + +func nextField() tea.Msg { return nextFieldMsg{} } + +func prevField() tea.Msg { return prevFieldMsg{} } + +func nextGroup() tea.Msg { return nextGroupMsg{} } + +func prevGroup() tea.Msg { return prevGroupMsg{} } + +// navKeys moves between fields. The form's first field has no previous +// field, and only enter submits from its last field. +type navKeys struct { + prev, next key.Binding +} + +func newNavKeys(next ...string) navKeys { + return navKeys{ + prev: key.NewBinding(key.WithKeys("shift+tab")), + next: key.NewBinding(key.WithKeys(next...)), + } +} + +func (n *navKeys) setPosition(first, last bool) { + n.prev.SetEnabled(!first) + if last { + n.next.SetKeys("enter") + } +} + +// Form shows one group of fields at a time and completes after the last group. +type Form struct { + groups []*Group + index int + + accessible bool + width int + quit key.Binding + + done bool + quitting bool + aborted bool + + submitCmd tea.Cmd + cancelCmd tea.Cmd +} + +// NewForm returns a form sized to the terminal width. +func NewForm(groups ...*Group) *Form { + f := &Form{ + groups: groups, + quit: key.NewBinding(key.WithKeys("ctrl+c")), + // Dumb terminals cannot redraw, so they get line prompts. + accessible: os.Getenv("TERM") == "dumb", + } + for g, group := range groups { + for i, field := range group.fields { + field.setPosition(g == 0 && i == 0, g == len(groups)-1 && i == len(group.fields)-1) + } + } + f.width = terminalWidth() + for _, group := range groups { + group.setWidth(f.width) + } + return f +} + +func terminalWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + return 80 + } + return width +} + +// WithAccessible replaces the interactive renderer with line prompts that +// read stdin, for screen readers and non-terminal input. +func (f *Form) WithAccessible(accessible bool) *Form { + f.accessible = accessible + return f +} + +// Run runs the form until it is submitted or cancelled. +func (f *Form) Run() error { + f.submitCmd = tea.Quit + f.cancelCmd = tea.Interrupt + if len(f.groups) == 0 { + return nil + } + if f.accessible { + for _, group := range f.groups { + for _, field := range group.fields { + field.Init() + field.Focus() + _ = field.RunAccessible(os.Stdout, os.Stdin) + fmt.Fprintln(os.Stdout) + } + } + return nil + } + m, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run() + if m.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + if err != nil { + return fmt.Errorf("form: %w", err) + } + return nil +} + +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + cmds := make([]tea.Cmd, 0, len(f.groups)+1) + for i, group := range f.groups { + group.active = i == 0 + cmds = append(cmds, group.Init()) + } + cmds = append(cmds, tea.RequestWindowSize) + return tea.Sequence(cmds...) +} + +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if f.done { + return f, nil + } + group := f.groups[f.index] + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // Size every group to the tallest one so pages don't jump. + needed := 0 + for _, g := range f.groups { + needed = max(needed, g.rawHeight()) + } + for _, g := range f.groups { + g.setHeight(min(needed, msg.Height)) + } + case tea.KeyPressMsg: + if key.Matches(msg, f.quit) { + f.aborted = true + f.quitting = true + f.done = true + return f, f.cancelCmd + } + case nextGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + if f.index == len(f.groups)-1 { + f.quitting = true + f.done = true + return f, f.submitCmd + } + f.index++ + f.groups[f.index].active = true + return f, f.groups[f.index].Init() + case prevGroupMsg: + if len(group.Errors()) > 0 { + return f, nil + } + if f.index > 0 { + f.index-- + } + f.groups[f.index].active = true + return f, f.groups[f.index].Init() + } + + return f, group.Update(msg) +} + +// View implements [tea.Model]. +func (f *Form) View() tea.View { + var v tea.View + v.ReportFocus = true + v.SetContent(f.render()) + return v +} + +func (f *Form) render() string { + if f.quitting { + return "" + } + return lipgloss.NewStyle().Render(f.groups[f.index].View()) +} + +// Group is a page of fields shown together. +type Group struct { + fields []Field + index int + title string + + viewport viewport.Model + width int + height int + active bool +} + +// NewGroup returns a group of the given fields. +func NewGroup(fields ...Field) *Group { + g := &Group{fields: fields, width: 80} + g.height = g.rawHeight() + g.viewport = viewport.New(viewport.WithWidth(g.width), viewport.WithHeight(g.height)) + return g +} + +// Title sets the heading shown above the group's fields. +func (g *Group) Title(title string) *Group { + g.title = title + return g +} + +func (g *Group) setWidth(width int) { + g.width = width + g.viewport.SetWidth(width) + for _, field := range g.fields { + field.setWidth(width) + } +} + +func (g *Group) setHeight(height int) { + g.height = height + h := height - g.titleFooterHeight() + g.viewport.SetHeight(h) + for _, field := range g.fields { + // A field height must not exceed the form height. + if h < lipgloss.Height(field.View()) { + field.setHeight(h) + } + } +} + +// Errors returns the validation errors of the group's fields. +func (g *Group) Errors() []error { + var errs []error + for _, field := range g.fields { + if err := field.Error(); err != nil { + errs = append(errs, err) + } + } + return errs +} + +// Init focuses the current field when the group is active. +func (g *Group) Init() tea.Cmd { + var cmd tea.Cmd + if g.active { + cmd = g.fields[g.index].Focus() + } + g.buildView() + return cmd +} + +func (g *Group) nextField() []tea.Cmd { + blur := g.fields[g.index].Blur() + if g.index == len(g.fields)-1 { + return []tea.Cmd{blur, nextGroup} + } + g.index++ + return []tea.Cmd{blur, g.fields[g.index].Focus()} +} + +func (g *Group) prevField() []tea.Cmd { + blur := g.fields[g.index].Blur() + if g.index == 0 { + return []tea.Cmd{blur, prevGroup} + } + g.index-- + return []tea.Cmd{blur, g.fields[g.index].Focus()} +} + +// Update routes key presses and pastes to the focused field and every other +// message to all fields. +func (g *Group) Update(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + for i, field := range g.fields { + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg: + default: + m, cmd := field.Update(msg) + g.fields[i] = m + cmds = append(cmds, cmd) + } + if i == g.index { + m, cmd := field.Update(msg) + g.fields[i] = m + cmds = append(cmds, cmd) + } + } + + switch msg.(type) { + case nextFieldMsg: + cmds = append(cmds, g.nextField()...) + case prevFieldMsg: + cmds = append(cmds, g.prevField()...) + } + + g.buildView() + return tea.Batch(cmds...) +} + +func (g *Group) content() (int, string) { + var fields strings.Builder + offset := 0 + for i, field := range g.fields { + fields.WriteString(field.View()) + if i == g.index { + offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) + } + if i < len(g.fields)-1 { + fields.WriteString("\n\n") + } + } + return offset, fields.String() +} + +func (g *Group) buildView() { + offset, content := g.content() + g.viewport.SetContent(content) + g.viewport.SetYOffset(offset) +} + +func (g *Group) header() string { + if g.title == "" { + return "" + } + return lipgloss.NewStyle().Render(wrap(g.title, g.width)) +} + +func (g *Group) footer() string { + var parts []string + for _, err := range g.Errors() { + parts = append(parts, wrap(focusedStyles.ErrorMessage.Render(err.Error()), g.width)) + } + return lipgloss.NewStyle().Render(strings.Join(parts, "\n")) +} + +func (g *Group) titleFooterHeight() int { + h := 0 + if s := g.header(); s != "" { + h += lipgloss.Height(s) + } + if s := g.footer(); s != "" { + h += lipgloss.Height(s) + } + return h +} + +// rawHeight is the group's full height without a viewport. +func (g *Group) rawHeight() int { + _, content := g.content() + return lipgloss.Height(content) + g.titleFooterHeight() +} + +// View renders the group. +func (g *Group) View() string { + var parts []string + if s := g.header(); s != "" { + parts = append(parts, s) + } + parts = append(parts, g.viewport.View()) + if s := g.footer(); s != "" { + parts = append(parts, "", s) + } + // A trailing space in the bottom-right cell scrolls some terminals. + last := len(parts) - 1 + parts[last] = strings.TrimSuffix(parts[last], " ") + return strings.Join(parts, "\n") +} + +func wrap(s string, limit int) string { + return lipgloss.Wrap(s, limit, ",.-; ") +} + +func noValidation(string) error { return nil } + +// Input is a single-line text field. +type Input struct { + value *string + title string + description string + textinput textinput.Model + validate func(string) error + err error + focused bool + width int + height int + nav navKeys +} + +// NewInput returns a single-line text field. +func NewInput() *Input { + return &Input{value: new(string), textinput: textinput.New(), validate: noValidation, nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value, which also supplies the initial text. +func (i *Input) Value(value *string) *Input { + i.value = value + i.textinput.SetValue(*value) + return i +} + +// Title sets the field title. +func (i *Input) Title(title string) *Input { i.title = title; return i } + +// Description sets the text shown under the title. +func (i *Input) Description(description string) *Input { i.description = description; return i } + +// Placeholder sets the text shown while the field is empty. +func (i *Input) Placeholder(placeholder string) *Input { + i.textinput.Placeholder = placeholder + return i +} + +// Password masks the typed characters. +func (i *Input) Password() *Input { + i.textinput.EchoMode = textinput.EchoPassword + return i +} + +// Validate sets a check that runs on submit and when the field loses focus. +func (i *Input) Validate(validate func(string) error) *Input { + i.validate = validate + return i +} + +func (i *Input) Error() error { return i.err } + +func (i *Input) Focus() tea.Cmd { + i.focused = true + return i.textinput.Focus() +} + +func (i *Input) Blur() tea.Cmd { + i.focused = false + *i.value = i.textinput.Value() + i.textinput.Blur() + i.err = i.validate(*i.value) + return nil +} + +func (i *Input) Init() tea.Cmd { + i.textinput.Blur() + return nil +} + +func (i *Input) Update(msg tea.Msg) (Field, tea.Cmd) { + var cmds []tea.Cmd + if msg, ok := msg.(tea.KeyPressMsg); ok { + i.err = nil + switch { + case key.Matches(msg, i.nav.prev): + cmds = append(cmds, prevField) + case key.Matches(msg, i.nav.next): + if i.err = i.validate(i.textinput.Value()); i.err != nil { + return i, nil + } + cmds = append(cmds, nextField) + } + } + var cmd tea.Cmd + i.textinput, cmd = i.textinput.Update(msg) + cmds = append(cmds, cmd) + *i.value = i.textinput.Value() + return i, tea.Batch(cmds...) +} + +func (i *Input) View() string { + styles := stylesFor(i.focused) + maxWidth := i.width - styles.Base.GetHorizontalFrameSize() + + st := i.textinput.Styles() + st.Cursor.Color = styles.TextInput.Cursor.GetForeground() + st.Focused.Prompt = styles.TextInput.Prompt + st.Focused.Text = styles.TextInput.Text + st.Focused.Placeholder = styles.TextInput.Placeholder + i.textinput.SetStyles(st) + + var sb strings.Builder + if i.title != "" { + sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth)) + "\n") + } + if i.description != "" { + sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth)) + "\n") + } + sb.WriteString(i.textinput.View()) + return styles.Base.Width(i.width).Height(i.height).Render(sb.String()) +} + +func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { + title := stylesFor(i.focused).Title.PaddingRight(1) + if i.textinput.EchoMode == textinput.EchoNormal { + *i.value = promptString(w, r, title.Render(cmp.Or(i.title, "Input:")), *i.value, i.validate) + return nil + } + fd, ok := r.(interface{ Fd() uintptr }) + if !ok { + return errors.New("password asking needs a tty") + } + value, err := promptPassword(w, fd.Fd(), title.Render(cmp.Or(i.title, "Password:")), i.validate) + if err != nil { + return err + } + *i.value = value + return nil +} + +func (i *Input) setWidth(width int) { + i.width = width + frame := stylesFor(i.focused).Base.GetHorizontalFrameSize() + prompt := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) + i.textinput.SetWidth(width - frame - prompt - 1) +} + +func (i *Input) setHeight(height int) { i.height = height } + +func (i *Input) setPosition(first, last bool) { i.nav.setPosition(first, last) } + +// Text is a multi-line text field; alt+enter or ctrl+j inserts a newline. +type Text struct { + value *string + title string + description string + textarea textarea.Model + validate func(string) error + err error + focused bool + width int + nav navKeys +} + +// NewText returns a multi-line text field. +func NewText() *Text { + ta := textarea.New() + ta.ShowLineNumbers = false + ta.Prompt = "" + st := ta.Styles() + st.Focused.CursorLine = lipgloss.NewStyle() + ta.SetStyles(st) + ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + return &Text{value: new(string), textarea: ta, validate: noValidation, nav: newNavKeys("tab", "enter")} +} + +// Value binds the field to value, which also supplies the initial text. +func (t *Text) Value(value *string) *Text { + t.value = value + t.textarea.SetValue(*value) + return t +} + +// Title sets the field title. +func (t *Text) Title(title string) *Text { t.title = title; return t } + +// Description sets the text shown under the title. +func (t *Text) Description(description string) *Text { t.description = description; return t } + +// Validate sets a check that runs on submit and when the field loses focus. +func (t *Text) Validate(validate func(string) error) *Text { + t.validate = validate + return t +} + +func (t *Text) Error() error { return t.err } + +func (t *Text) Focus() tea.Cmd { + t.focused = true + return t.textarea.Focus() +} + +func (t *Text) Blur() tea.Cmd { + t.focused = false + *t.value = t.textarea.Value() + t.textarea.Blur() + t.err = t.validate(*t.value) + return nil +} + +func (t *Text) Init() tea.Cmd { + t.textarea.Blur() + return nil +} + +func (t *Text) Update(msg tea.Msg) (Field, tea.Cmd) { + var cmds []tea.Cmd + if msg, ok := msg.(tea.KeyPressMsg); ok { + t.err = nil + switch { + case key.Matches(msg, t.nav.next): + if t.err = t.validate(t.textarea.Value()); t.err != nil { + return t, nil + } + cmds = append(cmds, nextField) + case key.Matches(msg, t.nav.prev): + if t.err = t.validate(t.textarea.Value()); t.err != nil { + return t, nil + } + cmds = append(cmds, prevField) + } + } + var cmd tea.Cmd + t.textarea, cmd = t.textarea.Update(msg) + cmds = append(cmds, cmd) + *t.value = t.textarea.Value() + return t, tea.Batch(cmds...) +} + +func (t *Text) View() string { + styles := stylesFor(t.focused) + st := t.textarea.Styles() + state := &st.Blurred + if t.focused { + state = &st.Focused + } + state.Placeholder = styles.TextInput.Placeholder + state.Text = styles.TextInput.Text + state.Prompt = styles.TextInput.Prompt + state.CursorLine = styles.TextInput.Text + st.Cursor.Color = styles.TextInput.Cursor.GetBackground() + t.textarea.SetStyles(st) + + maxWidth := t.width - styles.Base.GetHorizontalFrameSize() + var parts []string + if t.title != "" { + header := styles.Title.Render(wrap(t.title, maxWidth)) + if t.err != nil { + header += styles.ErrorIndicator.String() + } + parts = append(parts, header) + } + if t.description != "" { + parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) + } + parts = append(parts, t.textarea.View()) + return styles.Base.Render(strings.Join(parts, "\n")) +} + +func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { + prompt := stylesFor(t.focused).Title.PaddingRight(1).Render(cmp.Or(t.title, "Input:")) + *t.value = promptString(w, r, prompt, *t.value, t.validate) + return nil +} + +func (t *Text) setWidth(width int) { + t.width = width + t.textarea.SetWidth(width - stylesFor(t.focused).Base.GetHorizontalFrameSize()) +} + +func (t *Text) setHeight(height int) { + adjust := 0 + if t.title != "" { + adjust++ + } + if t.description != "" { + adjust++ + } + t.textarea.SetHeight(height - stylesFor(t.focused).Base.GetVerticalFrameSize() - adjust) +} + +func (t *Text) setPosition(first, last bool) { t.nav.setPosition(first, last) } + +// Option is a choice in a [Select]: Key is shown, Value is stored. +type Option struct { + Key string + Value string +} + +// NewOption returns a select option. +func NewOption(key, value string) Option { + return Option{Key: key, Value: value} +} + +var ( + selectUp = key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p")) + selectDown = key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n")) + selectTop = key.NewBinding(key.WithKeys("home", "g")) + selectBottom = key.NewBinding(key.WithKeys("end", "G")) + selectHalfUp = key.NewBinding(key.WithKeys("ctrl+u")) + selectHalfDown = key.NewBinding(key.WithKeys("ctrl+d")) +) + +// Select picks one option from a list. +type Select struct { + value *string + title string + description string + options []Option + selected int + focused bool + width int + height int + viewport viewport.Model + nav navKeys +} + +// NewSelect returns a single-choice list. +func NewSelect() *Select { + return &Select{value: new(string), nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value and moves the cursor to the matching option. +func (s *Select) Value(value *string) *Select { + s.value = value + for i, o := range s.options { + if o.Value == *value { + s.selected = i + break + } + } + s.updateValue() + return s +} + +// Title sets the field title. +func (s *Select) Title(title string) *Select { s.title = title; return s } + +// Description sets the text shown under the title. +func (s *Select) Description(description string) *Select { s.description = description; return s } + +// Options sets the choices. +func (s *Select) Options(options ...Option) *Select { + if len(options) == 0 { + return s + } + s.options = options + s.selectCurrent() + s.updateViewportSize() + s.updateValue() + return s +} + +func (s *Select) selectCurrent() { + for i, o := range s.options { + if o.Value == *s.value { + s.selected = i + break + } + } + s.ensureCursorVisible() +} + +func (s *Select) Error() error { return nil } + +func (s *Select) Focus() tea.Cmd { + s.focused = true + return nil +} + +func (s *Select) Blur() tea.Cmd { + s.focused = false + return nil +} + +func (s *Select) Init() tea.Cmd { return nil } + +func (s *Select) Update(msg tea.Msg) (Field, tea.Cmd) { + s.updateViewportSize() + msgKey, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil + } + last := len(s.options) - 1 + switch { + case key.Matches(msgKey, selectUp): + s.selected-- + if s.selected < 0 { + s.selected = last + s.viewport.GotoBottom() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msgKey, selectTop): + s.selected = 0 + s.viewport.GotoTop() + s.updateValue() + case key.Matches(msgKey, selectBottom): + s.selected = last + s.viewport.GotoBottom() + case key.Matches(msgKey, selectHalfUp): + s.selected = max(s.selected-s.viewport.Height()/2, 0) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msgKey, selectHalfDown): + s.selected = min(s.selected+s.viewport.Height()/2, last) + s.ensureCursorVisible() + s.updateValue() + case key.Matches(msgKey, selectDown): + s.selected++ + if s.selected > last { + s.selected = 0 + s.viewport.GotoTop() + } else { + s.ensureCursorVisible() + } + s.updateValue() + case key.Matches(msgKey, s.nav.prev): + if s.selected > last { + break + } + s.updateValue() + return s, prevField + case key.Matches(msgKey, s.nav.next): + if s.selected > last { + break + } + s.updateValue() + return s, nextField + } + s.ensureCursorVisible() + return s, nil +} + +func (s *Select) updateValue() { + if s.selected >= 0 && s.selected < len(s.options) { + *s.value = s.options[s.selected].Value + } +} + +func (s *Select) updateViewportSize() { + if s.height > 0 { + yoffset := 0 + if v := s.titleView(); v != "" { + yoffset += lipgloss.Height(v) + } + if v := s.descriptionView(); v != "" { + yoffset += lipgloss.Height(v) + } + s.viewport.SetHeight(max(1, s.height-yoffset)) + s.ensureCursorVisible() + } else { + s.viewport.SetHeight(lipgloss.Height(s.optionsView())) + } + if s.width > 0 { + s.viewport.SetWidth(s.width) + } else { + s.viewport.SetWidth(lipgloss.Width(s.optionsView())) + } +} + +func (s *Select) maxWidth() int { + return s.width - stylesFor(s.focused).Base.GetHorizontalFrameSize() +} + +func (s *Select) titleView() string { + return stylesFor(s.focused).Title.Render(wrap(s.title, s.maxWidth())) +} + +func (s *Select) descriptionView() string { + if s.description == "" { + return "" + } + return stylesFor(s.focused).Description.Render(wrap(s.description, s.maxWidth())) +} + +func (s *Select) optionsView() string { + var sb strings.Builder + for i, option := range s.options { + sb.WriteString(s.renderOption(option, i == s.selected)) + if i < len(s.options)-1 { + sb.WriteString("\n") + } + } + return sb.String() +} + +func (s *Select) renderOption(option Option, selected bool) string { + styles := stylesFor(s.focused) + cursor := styles.SelectSelector.String() + cursorWidth := lipgloss.Width(cursor) + label := wrap(option.Key, s.maxWidth()-cursorWidth) + if selected { + return lipgloss.JoinHorizontal(lipgloss.Left, cursor, styles.SelectedOption.Render(label)) + } + return lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", cursorWidth), styles.UnselectedOption.Render(label)) +} + +// ensureCursorVisible scrolls the viewport the minimum amount to show the +// selected option. +func (s *Select) ensureCursorVisible() { + offset, height := 0, 0 + for i, option := range s.options { + h := lipgloss.Height(s.renderOption(option, i == s.selected)) + if i == s.selected { + height = h + break + } + offset += h + } + if height <= 0 { + return + } + if y := s.viewport.YOffset(); offset < y { + s.viewport.ScrollUp(y - offset) + } else if end := y + s.viewport.Height(); offset+height > end { + s.viewport.ScrollDown(offset + height - end) + } +} + +func (s *Select) View() string { + s.viewport.SetContent(s.optionsView()) + var parts []string + if s.title != "" { + parts = append(parts, s.titleView()) + } + if s.description != "" { + parts = append(parts, s.descriptionView()) + } + parts = append(parts, s.viewport.View()) + return stylesFor(s.focused).Base.Width(s.width).Height(s.height).Render(strings.Join(parts, "\n")) +} + +func (s *Select) RunAccessible(w io.Writer, r io.Reader) error { + fmt.Fprintln(w, stylesFor(s.focused).Title.PaddingRight(1).Render(cmp.Or(s.title, "Select:"))) + for i, option := range s.options { + fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) + } + s.selectCurrent() + prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) + if len(s.options) == 1 { + prompt = "There is only one option available; enter the number 1:" + } + choice := promptInt(w, r, prompt, 1, len(s.options), s.selected+1) + *s.value = s.options[choice-1].Value + return nil +} + +func (s *Select) setWidth(width int) { + s.width = width + s.updateViewportSize() +} + +func (s *Select) setHeight(height int) { + s.height = height + s.updateViewportSize() +} + +func (s *Select) setPosition(first, last bool) { s.nav.setPosition(first, last) } + +var ( + confirmToggle = key.NewBinding(key.WithKeys("h", "l", "right", "left")) + confirmAccept = key.NewBinding(key.WithKeys("y", "Y")) + confirmReject = key.NewBinding(key.WithKeys("n", "N")) +) + +// Confirm is a yes/no choice. +type Confirm struct { + value *bool + title string + description string + focused bool + width int + height int + nav navKeys +} + +// NewConfirm returns a yes/no choice. +func NewConfirm() *Confirm { + return &Confirm{value: new(bool), nav: newNavKeys("enter", "tab")} +} + +// Value binds the field to value, which also supplies the initial choice. +func (c *Confirm) Value(value *bool) *Confirm { c.value = value; return c } + +// Title sets the field title. +func (c *Confirm) Title(title string) *Confirm { c.title = title; return c } + +// Description sets the text shown under the title. +func (c *Confirm) Description(description string) *Confirm { c.description = description; return c } + +func (c *Confirm) Error() error { return nil } + +func (c *Confirm) Focus() tea.Cmd { + c.focused = true + return nil +} + +func (c *Confirm) Blur() tea.Cmd { + c.focused = false + return nil +} + +func (c *Confirm) Init() tea.Cmd { return nil } + +func (c *Confirm) Update(msg tea.Msg) (Field, tea.Cmd) { + msgKey, ok := msg.(tea.KeyPressMsg) + if !ok { + return c, nil + } + switch { + case key.Matches(msgKey, confirmToggle): + *c.value = !*c.value + case key.Matches(msgKey, c.nav.prev): + return c, prevField + case key.Matches(msgKey, c.nav.next): + return c, nextField + case key.Matches(msgKey, confirmAccept): + *c.value = true + return c, nextField + case key.Matches(msgKey, confirmReject): + *c.value = false + return c, nextField + } + return c, nil +} + +func (c *Confirm) View() string { + styles := stylesFor(c.focused) + maxWidth := c.width - styles.Base.GetHorizontalFrameSize() + + var sb strings.Builder + if c.title != "" { + sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) + } + if c.description != "" { + sb.WriteString("\n" + styles.Description.Render(wrap(c.description, maxWidth))) + } + if c.title != "" || c.description != "" { + sb.WriteString("\n\n") + } + + yes, no := styles.BlurredButton.Render("Yes"), styles.FocusedButton.Render("No") + if *c.value { + yes, no = styles.FocusedButton.Render("Yes"), styles.BlurredButton.Render("No") + } + buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, no) + width := max(lipgloss.Width(buttons), lipgloss.Width(sb.String())) + sb.WriteString(lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(buttons)) + return styles.Base.Width(c.width).Height(c.height).Render(sb.String()) +} + +func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { + opts := "[y/N]" + if *c.value { + opts = "[Y/n]" + } + prompt := stylesFor(c.focused).Title.PaddingRight(1).Render(cmp.Or(c.title, "Choose"), opts) + *c.value = promptBool(w, r, prompt, *c.value) + return nil +} + +func (c *Confirm) setWidth(width int) { c.width = width } + +func (c *Confirm) setHeight(height int) { c.height = height } + +func (c *Confirm) setPosition(first, last bool) { c.nav.setPosition(first, last) } + +type fieldStyles struct { + Base lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + ErrorIndicator lipgloss.Style + ErrorMessage lipgloss.Style + + SelectSelector lipgloss.Style + SelectedOption lipgloss.Style + UnselectedOption lipgloss.Style + + FocusedButton lipgloss.Style + BlurredButton lipgloss.Style + + TextInput struct { + Cursor lipgloss.Style + Placeholder lipgloss.Style + Prompt lipgloss.Style + Text lipgloss.Style + } +} + +var focusedStyles, blurredStyles = newStyles() + +func newStyles() (focused, blurred fieldStyles) { + accent := lipgloss.Color("#38BDF8") + dimmed := lipgloss.Color("#64748B") + subtle := lipgloss.Color("#475569") + errColor := lipgloss.Color("#F87171") + + button := lipgloss.NewStyle().Padding(0, 2).MarginRight(1) + + focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) + focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") + focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") + focused.SelectSelector = lipgloss.NewStyle().SetString("> ") + focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) + focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) + focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + blurred = focused + blurred.Base = blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + + focused.Base = focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) + focused.Title = focused.Title.Foreground(accent).Bold(true) + focused.Description = focused.Description.Foreground(dimmed).Italic(true) + focused.ErrorIndicator = focused.ErrorIndicator.Foreground(errColor) + focused.ErrorMessage = focused.ErrorMessage.Foreground(errColor) + focused.SelectSelector = focused.SelectSelector.Foreground(accent).SetString("> ") + focused.SelectedOption = focused.SelectedOption.Foreground(accent).Bold(true) + focused.FocusedButton = focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) + focused.BlurredButton = focused.BlurredButton.Background(subtle) + focused.TextInput.Cursor = focused.TextInput.Cursor.Foreground(accent) + focused.TextInput.Placeholder = focused.TextInput.Placeholder.Foreground(subtle).Italic(true) + focused.TextInput.Prompt = focused.TextInput.Prompt.Foreground(accent) + + blurred.Base = blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) + blurred.Title = blurred.Title.Foreground(dimmed) + blurred.Description = blurred.Description.Foreground(subtle).Italic(true) + blurred.TextInput.Text = blurred.TextInput.Text.Foreground(dimmed) + blurred.TextInput.Placeholder = blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) + blurred.SelectedOption = blurred.SelectedOption.Foreground(dimmed) + blurred.SelectSelector = blurred.SelectSelector.Foreground(dimmed) + return focused, blurred +} + +func stylesFor(focused bool) *fieldStyles { + if focused { + return &focusedStyles + } + return &blurredStyles +} + +// promptString prints prompt and reads a line until validate accepts it. A +// blank answer, or end of input, yields defaultValue. +func promptString(out io.Writer, in io.Reader, prompt, defaultValue string, validate func(string) error) string { + scanner := bufio.NewScanner(in) + var input string + for { + fmt.Fprint(out, prompt) + if !scanner.Scan() { + fmt.Fprintln(out) + break + } + input = scanner.Text() + if err := validate(input); err != nil { + fmt.Fprintln(out, err) + continue + } + break + } + return cmp.Or(strings.TrimSpace(input), defaultValue) +} + +// promptInt reads a number in [low, high]; a blank answer picks defaultValue. +func promptInt(out io.Writer, in io.Reader, prompt string, low, high, defaultValue int) int { + input := promptString(out, in, prompt, strconv.Itoa(defaultValue), func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if i, err := strconv.Atoi(s); err == nil && i >= low && i <= high { + return nil + } + if low == high { + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be %d", low) + } + //lint:ignore ST1005 shown to the user as a prompt line + return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) + }) + choice, _ := strconv.Atoi(input) + return choice +} + +func parseBool(s string) (bool, error) { + switch strings.ToLower(s) { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + } + return false, errors.New("invalid input. please try again") +} + +// promptBool reads y/yes/n/no; a blank answer keeps defaultValue. +func promptBool(out io.Writer, in io.Reader, prompt string, defaultValue bool) bool { + def := "N" + if defaultValue { + def = "y" + } + input := promptString(out, in, prompt, def, func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + _, err := parseBool(s) + return err + }) + b, _ := parseBool(input) + return b +} + +// promptPassword reads a line from the terminal fd without echoing it. +func promptPassword(out io.Writer, fd uintptr, prompt string, validate func(string) error) (string, error) { + for { + fmt.Fprint(out, prompt) + pwd, err := term.ReadPassword(int(fd)) + if err != nil { + return "", err + } + fmt.Fprintln(out) + if err := validate(string(pwd)); err != nil { + fmt.Fprintln(out, err) + continue + } + return string(pwd), nil + } +} diff --git a/zSDKs/sdk-cli/internal/forms/group.go b/zSDKs/sdk-cli/internal/forms/group.go deleted file mode 100644 index c08940dc0..000000000 --- a/zSDKs/sdk-cli/internal/forms/group.go +++ /dev/null @@ -1,410 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "strings" - - "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Group is a collection of fields that are displayed together with a page of -// the form. While a group is displayed the form completer can switch between -// fields in the group. -// -// If any of the fields in a group have errors, the form will not be able to -// progress to the next group. -type Group struct { - // collection of fields - selector *selector[Field] - - // information - title string - description string - - // navigation - viewport viewport.Model - - // help - showHelp bool - help help.Model - - // errors - showErrors bool - - // group options - width int - height int - theme Theme - hasDarkBg bool - keymap *KeyMap - hide func() bool - active bool -} - -// NewGroup returns a new group with the given fields. -func NewGroup(fields ...Field) *Group { - selector := newSelector(fields) - group := &Group{ - selector: selector, - help: help.New(), - showHelp: true, - showErrors: true, - active: false, - } - - group.width = 80 - height := group.rawHeight() - v := viewport.New( - viewport.WithWidth(group.width), - viewport.WithHeight(height), - ) - group.viewport = v - group.height = height - - return group -} - -// Title sets the group's title. -func (g *Group) Title(title string) *Group { - g.title = title - return g -} - -// Description sets the group's description. -func (g *Group) Description(description string) *Group { - g.description = description - return g -} - -// WithShowHelp sets whether or not the group's help should be shown. -func (g *Group) WithShowHelp(show bool) *Group { - g.showHelp = show - return g -} - -// WithShowErrors sets whether or not the group's errors should be shown. -func (g *Group) WithShowErrors(show bool) *Group { - g.showErrors = show - return g -} - -// WithTheme sets the theme on a group. -func (g *Group) WithTheme(t Theme) *Group { - g.theme = t - g.help.Styles = t.Theme(g.hasDarkBg).Help - g.selector.Range(func(_ int, field Field) bool { - field.WithTheme(t) - return true - }) - if g.height <= 0 { - g.WithHeight(g.rawHeight()) - } - return g -} - -// WithKeyMap sets the keymap on a group. -func (g *Group) WithKeyMap(k *KeyMap) *Group { - g.keymap = k - g.selector.Range(func(_ int, field Field) bool { - field.WithKeyMap(k) - return true - }) - return g -} - -// WithWidth sets the width on a group. -func (g *Group) WithWidth(width int) *Group { - g.width = width - g.viewport.SetWidth(width) - g.help.SetWidth(width) - g.selector.Range(func(_ int, field Field) bool { - field.WithWidth(width) - return true - }) - return g -} - -// WithHeight sets the height on a group. -func (g *Group) WithHeight(height int) *Group { - g.height = height - h := height - g.titleFooterHeight() - g.viewport.SetHeight(h) - g.selector.Range(func(_ int, field Field) bool { - // A field height must not exceed the form height. - if h < lipgloss.Height(field.View()) { - field.WithHeight(h) - } - return true - }) - return g -} - -// WithHide sets whether this group should be skipped. -func (g *Group) WithHide(hide bool) *Group { - g.WithHideFunc(func() bool { return hide }) - return g -} - -// WithHideFunc sets the function that checks if this group should be skipped. -func (g *Group) WithHideFunc(hideFunc func() bool) *Group { - g.hide = hideFunc - return g -} - -// Errors returns the groups' fields' errors. -func (g *Group) Errors() []error { - var errs []error - g.selector.Range(func(_ int, field Field) bool { - if err := field.Error(); err != nil { - errs = append(errs, err) - } - return true - }) - return errs -} - -// updateFieldMsg is a message to update the fields of a group that is currently -// displayed. -// -// This is used to update all TitleFunc, DescriptionFunc, and ...Func update -// methods to make all fields dynamically update based on user input. -type updateFieldMsg struct{} - -// nextFieldMsg is a message to move to the next field, -// -// each field controls when to send this message such that it is able to use -// different key bindings or events to trigger group progression. -type nextFieldMsg struct{} - -// prevFieldMsg is a message to move to the previous field. -// -// each field controls when to send this message such that it is able to use -// different key bindings or events to trigger group progression. -type prevFieldMsg struct{} - -// NextField is the command to move to the next field. -func NextField() tea.Msg { - return nextFieldMsg{} -} - -// PrevField is the command to move to the previous field. -func PrevField() tea.Msg { - return prevFieldMsg{} -} - -// Init initializes the group. -func (g *Group) Init() tea.Cmd { - var cmds []tea.Cmd - - cmds = append(cmds, func() tea.Msg { return updateFieldMsg{} }) - - if g.selector.Selected().Skip() { - if g.selector.OnLast() { - cmds = append(cmds, g.prevField()...) - } else if g.selector.OnFirst() { - cmds = append(cmds, g.nextField()...) - } - return tea.Batch(cmds...) - } - - if g.active { - cmd := g.selector.Selected().Focus() - cmds = append(cmds, cmd) - } - g.buildView() - return tea.Batch(cmds...) -} - -// nextField moves to the next field. -func (g *Group) nextField() []tea.Cmd { - blurCmd := g.selector.Selected().Blur() - if g.selector.OnLast() { - return []tea.Cmd{blurCmd, nextGroup} - } - g.selector.Next() - for g.selector.Selected().Skip() { - if g.selector.OnLast() { - return []tea.Cmd{blurCmd, nextGroup} - } - g.selector.Next() - } - focusCmd := g.selector.Selected().Focus() - return []tea.Cmd{blurCmd, focusCmd} -} - -// prevField moves to the previous field. -func (g *Group) prevField() []tea.Cmd { - blurCmd := g.selector.Selected().Blur() - if g.selector.OnFirst() { - return []tea.Cmd{blurCmd, prevGroup} - } - g.selector.Prev() - for g.selector.Selected().Skip() { - if g.selector.OnFirst() { - return []tea.Cmd{blurCmd, prevGroup} - } - g.selector.Prev() - } - focusCmd := g.selector.Selected().Focus() - return []tea.Cmd{blurCmd, focusCmd} -} - -// Update updates the group. -func (g *Group) Update(msg tea.Msg) (Model, tea.Cmd) { - var cmds []tea.Cmd - - // Update all the fields in the group. - g.selector.Range(func(i int, field Field) bool { - switch msg := msg.(type) { - case tea.KeyPressMsg, tea.PasteMsg: - break - default: - m, cmd := field.Update(msg) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - } - if g.selector.Index() == i { - m, cmd := field.Update(msg) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - } - m, cmd := field.Update(updateFieldMsg{}) - g.selector.Set(i, m.(Field)) - cmds = append(cmds, cmd) - return true - }) - - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - g.hasDarkBg = msg.IsDark() - case nextFieldMsg: - cmds = append(cmds, g.nextField()...) - case prevFieldMsg: - cmds = append(cmds, g.prevField()...) - } - - g.buildView() - - return g, tea.Batch(cmds...) -} - -func (g *Group) getTheme() *Styles { - if theme := g.theme; theme != nil { - return theme.Theme(g.hasDarkBg) - } - return ThemeFunc(themeCharm).Theme(g.hasDarkBg) -} - -func (g *Group) styles() GroupStyles { return g.getTheme().Group } - -func (g *Group) getContent() (int, string) { - var fields strings.Builder - offset := 0 - - gap := g.getTheme().FieldSeparator.Render() - - // if the focused field is requesting it be zoomed, only show that field. - if g.selector.Selected().Zoom() { - g.selector.Selected().WithHeight(g.height) - fields.WriteString(g.selector.Selected().View()) - } else { - g.selector.Range(func(i int, field Field) bool { - fields.WriteString(field.View()) - if i == g.selector.Index() { - offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) - } - if i < g.selector.Total()-1 { - fields.WriteString(gap) - } - return true - }) - } - - return offset, fields.String() -} - -func (g *Group) buildView() { - offset, content := g.getContent() - g.viewport.SetContent(content) - g.viewport.SetYOffset(offset) -} - -// Header renders the group's header only (no content). -func (g *Group) Header() string { - styles := g.styles() - var parts []string - if g.title != "" { - parts = append(parts, styles.Title.Render(wrap(g.title, g.width))) - } - if g.description != "" { - parts = append(parts, styles.Description.Render(wrap(g.description, g.width))) - } - return strings.Join(parts, "\n") -} - -// titleFooterHeight returns the height of the footer + header. -func (g *Group) titleFooterHeight() int { - h := 0 - if s := g.Header(); s != "" { - h += lipgloss.Height(s) - } - if s := g.Footer(); s != "" { - h += lipgloss.Height(s) - } - return h -} - -// rawHeight returns the full height of the group, without using a viewport. -func (g *Group) rawHeight() int { - return lipgloss.Height(g.Content()) + g.titleFooterHeight() -} - -// View renders the group. -func (g *Group) View() string { - var parts []string - if s := g.Header(); s != "" { - parts = append(parts, s) - } - parts = append(parts, g.viewport.View()) - if s := g.Footer(); s != "" { - // append an empty line, and the footer (usually the help). - parts = append(parts, "", s) - } - if len(parts) > 0 { - // Trim suffix spaces from the last part as it can accidentally - // scroll the view up on some terminals (like Apple's Terminal.app) - // when we right to the bottom rightmost corner cell. - lastIdx := len(parts) - 1 - parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], " ") - } - return strings.Join(parts, "\n") -} - -// Content renders the group's content only (no footer). -func (g *Group) Content() string { - _, content := g.getContent() - return content -} - -// Footer renders the group's footer only (no content). -func (g *Group) Footer() string { - var parts []string - errors := g.Errors() - if g.showHelp && len(errors) <= 0 { - parts = append(parts, g.help.ShortHelpView(g.selector.Selected().KeyBinds())) - } - if g.showErrors { - for _, err := range errors { - parts = append(parts, wrap( - g.getTheme().Focused.ErrorMessage.Render(err.Error()), - g.width, - )) - } - } - return g.styles().Base. - Render(strings.Join(parts, "\n")) -} diff --git a/zSDKs/sdk-cli/internal/forms/keymap.go b/zSDKs/sdk-cli/internal/forms/keymap.go deleted file mode 100644 index 382dfd28c..000000000 --- a/zSDKs/sdk-cli/internal/forms/keymap.go +++ /dev/null @@ -1,104 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import "charm.land/bubbles/v2/key" - -// KeyMap is the keybindings to navigate the form. -type KeyMap struct { - Quit key.Binding - - Confirm ConfirmKeyMap - Input InputKeyMap - Select SelectKeyMap - Text TextKeyMap -} - -// InputKeyMap is the keybindings for input fields. -type InputKeyMap struct { - AcceptSuggestion key.Binding - Next key.Binding - Prev key.Binding - Submit key.Binding -} - -// TextKeyMap is the keybindings for text fields. -type TextKeyMap struct { - Next key.Binding - Prev key.Binding - NewLine key.Binding - Editor key.Binding - Submit key.Binding -} - -// SelectKeyMap is the keybindings for select fields. -type SelectKeyMap struct { - Next key.Binding - Prev key.Binding - Up key.Binding - Down key.Binding - HalfPageUp key.Binding - HalfPageDown key.Binding - GotoTop key.Binding - GotoBottom key.Binding - Left key.Binding - Right key.Binding - Filter key.Binding - SetFilter key.Binding - ClearFilter key.Binding - Submit key.Binding -} - -// ConfirmKeyMap is the keybindings for confirm fields. -type ConfirmKeyMap struct { - Next key.Binding - Prev key.Binding - Toggle key.Binding - Submit key.Binding - Accept key.Binding - Reject key.Binding -} - -// NewDefaultKeyMap returns a new default keymap. -func NewDefaultKeyMap() *KeyMap { - return &KeyMap{ - Quit: key.NewBinding(key.WithKeys("ctrl+c")), - Input: InputKeyMap{ - AcceptSuggestion: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "complete")), - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - }, - Text: TextKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("tab", "enter"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - NewLine: key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"), key.WithHelp("alt+enter / ctrl+j", "new line")), - Editor: key.NewBinding(key.WithKeys("ctrl+e"), key.WithHelp("ctrl+e", "open editor")), - }, - Select: SelectKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "select")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - Up: key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n"), key.WithHelp("↓", "down")), - Left: key.NewBinding(key.WithKeys("h", "left"), key.WithHelp("←", "left"), key.WithDisabled()), - Right: key.NewBinding(key.WithKeys("l", "right"), key.WithHelp("→", "right"), key.WithDisabled()), - Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), - SetFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "set filter"), key.WithDisabled()), - ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), key.WithDisabled()), - HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "½ page up")), - HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "½ page down")), - GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start")), - GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end")), - }, - Confirm: ConfirmKeyMap{ - Prev: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "back")), - Next: key.NewBinding(key.WithKeys("enter", "tab"), key.WithHelp("enter", "next")), - Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - Toggle: key.NewBinding(key.WithKeys("h", "l", "right", "left"), key.WithHelp("←/→", "toggle")), - Accept: key.NewBinding(key.WithKeys("y", "Y"), key.WithHelp("y", "Yes")), - Reject: key.NewBinding(key.WithKeys("n", "N"), key.WithHelp("n", "No")), - }, - } -} diff --git a/zSDKs/sdk-cli/internal/forms/option.go b/zSDKs/sdk-cli/internal/forms/option.go deleted file mode 100644 index b794fae53..000000000 --- a/zSDKs/sdk-cli/internal/forms/option.go +++ /dev/null @@ -1,19 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -// Option is an option for select fields. -type Option[T comparable] struct { - Key string - Value T -} - -// NewOption returns a new select option. -func NewOption[T comparable](key string, value T) Option[T] { - return Option[T]{Key: key, Value: value} -} - -// String returns the key of the option. -func (o Option[T]) String() string { - return o.Key -} diff --git a/zSDKs/sdk-cli/internal/forms/selector.go b/zSDKs/sdk-cli/internal/forms/selector.go deleted file mode 100644 index e94d06b8e..000000000 --- a/zSDKs/sdk-cli/internal/forms/selector.go +++ /dev/null @@ -1,98 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -// selector is a helper type for selecting items. -type selector[T any] struct { - items []T - index int -} - -// newSelector creates a new item selector. -func newSelector[T any](items []T) *selector[T] { - return &selector[T]{ - items: items, - } -} - -// Append adds an item to the selector. -func (s *selector[T]) Append(item T) { - s.items = append(s.items, item) -} - -// Next moves the selector to the next item. -func (s *selector[T]) Next() { - if s.index < len(s.items)-1 { - s.index++ - } -} - -// Prev moves the selector to the previous item. -func (s *selector[T]) Prev() { - if s.index > 0 { - s.index-- - } -} - -// OnFirst returns true if the selector is on the first item. -func (s *selector[T]) OnFirst() bool { - return s.index == 0 -} - -// OnLast returns true if the selector is on the last item. -func (s *selector[T]) OnLast() bool { - return s.index == len(s.items)-1 -} - -// Selected returns the index of the current selected item. -func (s *selector[T]) Selected() T { - return s.items[s.index] -} - -// Index returns the index of the current selected item. -func (s *selector[T]) Index() int { - return s.index -} - -// Total returns the total number of items. -func (s *selector[T]) Total() int { - return len(s.items) -} - -// SetIndex sets the selected item. -func (s *selector[T]) SetIndex(i int) { - if i < 0 || i >= len(s.items) { - return - } - s.index = i -} - -// Get returns the item at the given index. -func (s *selector[T]) Get(i int) T { - return s.items[i] -} - -// Set sets the item at the given index. -func (s *selector[T]) Set(i int, item T) { - s.items[i] = item -} - -// Range iterates over the items. -// The callback function should return true to continue the iteration. -func (s *selector[T]) Range(f func(i int, item T) bool) { - for i, item := range s.items { - if !f(i, item) { - break - } - } -} - -// ReverseRange iterates over the items in reverse. -// The callback function should return true to continue the iteration. -func (s *selector[T]) ReverseRange(f func(i int, item T) bool) { - for i := len(s.items) - 1; i >= 0; i-- { - if !f(i, s.items[i]) { - break - } - } -} diff --git a/zSDKs/sdk-cli/internal/forms/theme.go b/zSDKs/sdk-cli/internal/forms/theme.go deleted file mode 100644 index 28eeb5ec9..000000000 --- a/zSDKs/sdk-cli/internal/forms/theme.go +++ /dev/null @@ -1,157 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "charm.land/bubbles/v2/help" - "charm.land/lipgloss/v2" -) - -// Theme represents a theme for a form. -type Theme interface { - Theme(isDark bool) *Styles -} - -// ThemeFunc is a function that returns a new theme. -type ThemeFunc func(isDark bool) *Styles - -// Theme implements the Theme interface. -func (f ThemeFunc) Theme(isDark bool) *Styles { - return f(isDark) -} - -// Styles is a collection of styles for components of the form. -// Themes can be applied to a form using the WithTheme option. -type Styles struct { - Form FormStyles - Group GroupStyles - FieldSeparator lipgloss.Style - Blurred FieldStyles - Focused FieldStyles - Help help.Styles -} - -// FormStyles are the styles for a form. -type FormStyles struct { - Base lipgloss.Style -} - -// GroupStyles are the styles for a group. -type GroupStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style -} - -// FieldStyles are the styles for input fields. -type FieldStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - ErrorIndicator lipgloss.Style - ErrorMessage lipgloss.Style - - // Select styles. - SelectSelector lipgloss.Style // Selection indicator - NextIndicator lipgloss.Style - PrevIndicator lipgloss.Style - SelectedOption lipgloss.Style - UnselectedOption lipgloss.Style - - // Textinput and teatarea styles. - TextInput TextInputStyles - - // Confirm styles. - FocusedButton lipgloss.Style - BlurredButton lipgloss.Style -} - -// TextInputStyles are the styles for text inputs. -type TextInputStyles struct { - Cursor lipgloss.Style - CursorText lipgloss.Style - Placeholder lipgloss.Style - Prompt lipgloss.Style - Text lipgloss.Style -} - -const ( - buttonPaddingHorizontal = 2 - buttonPaddingVertical = 0 -) - -// ThemeBase returns a new base theme with general styles to be inherited by -// other themes. -func ThemeBase(bool) *Styles { - var t Styles - - t.Form.Base = lipgloss.NewStyle() - t.Group.Base = lipgloss.NewStyle() - t.FieldSeparator = lipgloss.NewStyle().SetString("\n\n") - - button := lipgloss.NewStyle(). - Padding(buttonPaddingVertical, buttonPaddingHorizontal). - MarginRight(1) - - // Focused styles. - t.Focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) - t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") - t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") - t.Focused.SelectSelector = lipgloss.NewStyle().SetString("> ") - t.Focused.NextIndicator = lipgloss.NewStyle().MarginLeft(1).SetString("→") - t.Focused.PrevIndicator = lipgloss.NewStyle().MarginRight(1).SetString("←") - t.Focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) - t.Focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) - t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - t.Help = help.New().Styles - - // Blurred styles. - t.Blurred = t.Focused - t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - t.Blurred.NextIndicator = lipgloss.NewStyle() - t.Blurred.PrevIndicator = lipgloss.NewStyle() - - return &t -} - -// themeCharm returns a new theme based on the Charm color scheme. -func themeCharm(isDark bool) *Styles { - t := ThemeBase(isDark) - lightDark := lipgloss.LightDark(isDark) - - var ( - normalFg = lightDark(lipgloss.Color("252"), lipgloss.Color("235")) - indigo = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9")) - cream = lightDark(lipgloss.Color("#FFFDF5"), lipgloss.Color("#FFFDF5")) - fuchsia = lipgloss.Color("#F780E2") - green = lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87")) - red = lightDark(lipgloss.Color("#FF4672"), lipgloss.Color("#ED567A")) - ) - - t.Focused.Base = t.Focused.Base.BorderForeground(lipgloss.Color("238")) - t.Focused.Title = t.Focused.Title.Foreground(indigo).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(lightDark(lipgloss.Color(""), lipgloss.Color("243"))) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(fuchsia) - t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(fuchsia) - t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(fuchsia) - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) - t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(normalFg) - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(cream).Background(fuchsia) - t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lightDark(lipgloss.Color("237"), lipgloss.Color("252"))) - - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(green) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("238"))) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(fuchsia) - - t.Blurred = t.Focused - t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) - t.Blurred.NextIndicator = lipgloss.NewStyle() - t.Blurred.PrevIndicator = lipgloss.NewStyle() - - t.Group.Title = t.Focused.Title - t.Group.Description = t.Focused.Description - return t -} diff --git a/zSDKs/sdk-cli/internal/forms/wrap.go b/zSDKs/sdk-cli/internal/forms/wrap.go deleted file mode 100644 index cba9b9250..000000000 --- a/zSDKs/sdk-cli/internal/forms/wrap.go +++ /dev/null @@ -1,9 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import "charm.land/lipgloss/v2" - -func wrap(s string, limit int) string { - return lipgloss.Wrap(s, limit, ",.-; ") -} diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index d700b43ac..5c2cc80ce 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -17,7 +17,6 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/spf13/pflag" - "golang.org/x/term" "openapi/internal/config" "openapi/internal/flagutil" @@ -611,51 +610,6 @@ func applyAnswers(cmd *cobra.Command, args []string, fields []PromptField, answe return effective, nil } -// formWidth returns the terminal width for sizing forms, with a sensible default. -func formWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 - } - return width -} - -// formTheme builds the interactive prompt theme. -func formTheme() forms.Theme { - return forms.ThemeFunc(formStyles) -} - -func formStyles(isDark bool) *forms.Styles { - t := *forms.ThemeBase(isDark) - - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - - t.Focused.Base = t.Focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - t.Focused.Title = t.Focused.Title.Foreground(accent).Bold(true) - t.Focused.Description = t.Focused.Description.Foreground(dimmed).Italic(true) - t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errColor) - t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errColor) - t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(accent).SetString("> ") - t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(accent).Bold(true) - t.Focused.FocusedButton = t.Focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - t.Focused.BlurredButton = t.Focused.BlurredButton.Background(subtle) - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(accent) - t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(accent) - - t.Blurred.Base = t.Blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - t.Blurred.Title = t.Blurred.Title.Foreground(dimmed) - t.Blurred.Description = t.Blurred.Description.Foreground(subtle).Italic(true) - t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dimmed) - t.Blurred.TextInput.Placeholder = t.Blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - t.Blurred.SelectedOption = t.Blurred.SelectedOption.Foreground(dimmed) - t.Blurred.SelectSelector = t.Blurred.SelectSelector.Foreground(dimmed) - return &t -} - func printCommandHeader(cmd *cobra.Command, totalRequired, totalOptional int) { accent := lipgloss.Color("#38BDF8") dimmed := lipgloss.Color("#64748B") @@ -703,9 +657,8 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) - theme, width := formTheme(), formWidth() for _, index := range required { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -714,14 +667,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field, theme, width) + answer, err := runPromptField(field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -737,14 +690,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))). - WithWidth(width).WithTheme(theme).WithShowHelp(false) + Description(strings.Join(names, ", ")).Value(&fillOptional))) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index], theme, width) + answer, err := runPromptField(fields[index]) if err != nil { return nil, err } @@ -761,9 +713,9 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { +func runPromptField(field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field, theme, width) + return runRepeatableFlagPrompt(field) } var answer PromptAnswer @@ -775,7 +727,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) answer.Set = true answer.Values = []string{strconv.FormatBool(value)} - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -783,10 +735,10 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw return answer, nil } value := "" - formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options( + formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options( forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), ).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -794,15 +746,15 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw case "enum": value := "" - options := make([]forms.Option[string], 0, len(field.Options)+1) + options := make([]forms.Option, 0, len(field.Options)+1) if !field.Required { - options = append(options, forms.NewOption[string]("Skip (use default)", "")) + options = append(options, forms.NewOption("Skip (use default)", "")) } for _, option := range field.Options { options = append(options, forms.NewOption(option, option)) } - formField = forms.NewSelect[string]().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -872,7 +824,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw } formField = input } - form := forms.NewForm(forms.NewGroup(formField)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(formField)) if err := form.Run(); err != nil { return PromptAnswer{}, err } @@ -882,7 +834,7 @@ func runPromptField(field PromptField, theme forms.Theme, width int) (PromptAnsw return PromptAnswer{Set: true, Values: []string{value}}, nil } -func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (PromptAnswer, error) { +func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -901,7 +853,7 @@ func runRepeatableFlagPrompt(field PromptField, theme forms.Theme, width int) (P return nil }) } - form := forms.NewForm(forms.NewGroup(input)).WithWidth(width).WithTheme(theme).WithShowHelp(false) + form := forms.NewForm(forms.NewGroup(input)) if err := form.Run(); err != nil { return PromptAnswer{}, err } From 1c71c5ed42b4b3ac56d3288d762100e9006ed2ba Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Wed, 7 Oct 2026 23:35:34 +0100 Subject: [PATCH 3/9] chore(cli): replace the adapted forms package with a purpose-built one internal/forms is rewritten around what generated CLIs ask for: pages of text, secret, JSON, choice and yes/no fields. One Field type and one Form model replace the per-field models, groups, viewports and themes of the previous package, which was adapted from huh and is removed along with its license notice. The API binds each answer at construction (NewInput(&v), NewSelect(&v, options...), NewConfirm(&b), NewText(&v)), groups fields with NewPage(title, fields...), and runs with New(pages...).Accessible(b).Run(). Selects show a window of options sized to the terminal height. Line-prompt mode now reads all answers through one buffered reader, so piped input reaches every field instead of being consumed by the first, and a secret read from a non-terminal is taken from the next line rather than failing. Blank answers keep the current value and end of input stops prompting, as before. The package ships with unit tests covering navigation, validation, selects, confirms, abort, and the line prompts. --- templates/perms.go | 1 + .../auxiliary/internal/forms/forms.go.stmpl | 1571 +++++------------ .../internal/forms/forms_test.go.stmpl | 283 +++ .../internal/interactive/interactive.go.stmpl | 126 +- templates/templates/cli/configure.go.stmpl | 20 +- templates/templates/cli/includes/security.ts | 182 +- zSDKs/sdk-cli/.speakeasy/gen.lock | 10 +- zSDKs/sdk-cli/internal/cli/auth.go | 114 +- zSDKs/sdk-cli/internal/cli/configure.go | 117 +- zSDKs/sdk-cli/internal/forms/forms.go | 1571 +++++------------ zSDKs/sdk-cli/internal/forms/forms_test.go | 283 +++ .../internal/interactive/interactive.go | 126 +- 12 files changed, 1719 insertions(+), 2685 deletions(-) create mode 100644 templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl create mode 100644 zSDKs/sdk-cli/internal/forms/forms_test.go diff --git a/templates/perms.go b/templates/perms.go index 2df504154..34c05bebc 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -43,6 +43,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, "templates/cli/auxiliary/internal/forms": 0o20000000755, "templates/cli/auxiliary/internal/forms/forms.go.stmpl": 0o644, + "templates/cli/auxiliary/internal/forms/forms_test.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive": 0o20000000755, "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl index 9fab65734..7eb0ea9f7 100644 --- a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -1,37 +1,11 @@ {{- if isAnyInteractiveEnabled}} -// MIT License -// -// Copyright (c) 2023–2026 Charm -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -// Package forms renders the terminal forms used by guided prompts: text -// inputs, multi-line text, selects, and confirmations, with an accessible -// line-prompt mode for screen readers and non-TTY input. -// -// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), -// reduced to the fields and form options this CLI uses. +// Package forms asks the questions behind the CLI's guided setup: pages of +// text, secret, JSON, choice and yes/no fields. Terminals that can redraw get +// an interactive form; everything else gets plain line prompts. package forms import ( "bufio" - "cmp" "errors" "fmt" "io" @@ -39,1240 +13,585 @@ import ( "strconv" "strings" - "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "golang.org/x/term" ) -// ErrUserAborted is returned by Run when the user cancels the form. +// ErrUserAborted is returned by Run when the user cancels with ctrl+c. var ErrUserAborted = errors.New("user aborted") -// Field is one input control on a form. -type Field interface { - Init() tea.Cmd - Update(msg tea.Msg) (Field, tea.Cmd) - View() string - Focus() tea.Cmd - Blur() tea.Cmd - Error() error - RunAccessible(w io.Writer, r io.Reader) error - - setWidth(width int) - setHeight(height int) - setPosition(first, last bool) -} - -type nextFieldMsg struct{} - -type prevFieldMsg struct{} - -type nextGroupMsg struct{} - -type prevGroupMsg struct{} - -func nextField() tea.Msg { return nextFieldMsg{} } - -func prevField() tea.Msg { return prevFieldMsg{} } - -func nextGroup() tea.Msg { return nextGroupMsg{} } +var ( + accent = {{themeAccent}} + dimmed = {{themeDimmed}} + subtle = {{themeSubtle}} + errColor = {{themeError}} +) -func prevGroup() tea.Msg { return prevGroupMsg{} } +type fieldKind int -// navKeys moves between fields. The form's first field has no previous -// field, and only enter submits from its last field. -type navKeys struct { - prev, next key.Binding -} +const ( + kindInput fieldKind = iota + kindText + kindSelect + kindConfirm +) -func newNavKeys(next ...string) navKeys { - return navKeys{ - prev: key.NewBinding(key.WithKeys("shift+tab")), - next: key.NewBinding(key.WithKeys(next...)), - } +// Option is a choice in a select field: Label is shown, Value is stored. +type Option struct { + Label string + Value string } -func (n *navKeys) setPosition(first, last bool) { - n.prev.SetEnabled(!first) - if last { - n.next.SetKeys("enter") - } +// NewOption returns a select option. +func NewOption(label, value string) Option { + return Option{Label: label, Value: value} } -// Form shows one group of fields at a time and completes after the last group. -type Form struct { - groups []*Group - index int - - accessible bool - width int - quit key.Binding +// Field is one question. Its answer is written to the variable passed to the +// constructor as the user edits it. +type Field struct { + kind fieldKind + title string + description string + validate func(string) error + err error - done bool - quitting bool - aborted bool + text *string // input, text and select answers + yes *bool // confirm answer + secret bool - submitCmd tea.Cmd - cancelCmd tea.Cmd -} + options []Option + cursor int -// NewForm returns a form sized to the terminal width. -func NewForm(groups ...*Group) *Form { - f := &Form{ - groups: groups, - quit: key.NewBinding(key.WithKeys("ctrl+c")), - // Dumb terminals cannot redraw, so they get line prompts. - accessible: os.Getenv("TERM") == "dumb", - } - for g, group := range groups { - for i, field := range group.fields { - field.setPosition(g == 0 && i == 0, g == len(groups)-1 && i == len(group.fields)-1) - } - } - f.width = terminalWidth() - for _, group := range groups { - group.setWidth(f.width) - } - return f + input textinput.Model + area textarea.Model } -func terminalWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - return 80 - } - return width +// NewInput asks for a single line of text, starting from *value. +func NewInput(value *string) *Field { + in := textinput.New() + in.SetValue(*value) + return &Field{kind: kindInput, text: value, input: in} } -// WithAccessible replaces the interactive renderer with line prompts that -// read stdin, for screen readers and non-terminal input. -func (f *Form) WithAccessible(accessible bool) *Form { - f.accessible = accessible - return f +// NewText asks for multi-line text such as JSON, starting from *value. +// alt+enter or ctrl+j inserts a newline; enter moves on. +func NewText(value *string) *Field { + area := textarea.New() + area.ShowLineNumbers = false + area.Prompt = "" + area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetValue(*value) + return &Field{kind: kindText, text: value, area: area} } -// Run runs the form until it is submitted or cancelled. -func (f *Form) Run() error { - f.submitCmd = tea.Quit - f.cancelCmd = tea.Interrupt - if len(f.groups) == 0 { - return nil - } - if f.accessible { - for _, group := range f.groups { - for _, field := range group.fields { - field.Init() - field.Focus() - _ = field.RunAccessible(os.Stdout, os.Stdin) - fmt.Fprintln(os.Stdout) - } - } - return nil - } - m, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run() - if m.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { - return ErrUserAborted - } - if err != nil { - return fmt.Errorf("form: %w", err) - } - return nil -} - -// Init implements [tea.Model]. -func (f *Form) Init() tea.Cmd { - cmds := make([]tea.Cmd, 0, len(f.groups)+1) - for i, group := range f.groups { - group.active = i == 0 - cmds = append(cmds, group.Init()) - } - cmds = append(cmds, tea.RequestWindowSize) - return tea.Sequence(cmds...) -} - -// Update implements [tea.Model]. -func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if f.done { - return f, nil - } - group := f.groups[f.index] - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - // Size every group to the tallest one so pages don't jump. - needed := 0 - for _, g := range f.groups { - needed = max(needed, g.rawHeight()) - } - for _, g := range f.groups { - g.setHeight(min(needed, msg.Height)) - } - case tea.KeyPressMsg: - if key.Matches(msg, f.quit) { - f.aborted = true - f.quitting = true - f.done = true - return f, f.cancelCmd - } - case nextGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - if f.index == len(f.groups)-1 { - f.quitting = true - f.done = true - return f, f.submitCmd - } - f.index++ - f.groups[f.index].active = true - return f, f.groups[f.index].Init() - case prevGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - if f.index > 0 { - f.index-- +// NewSelect asks for one of options, starting at the option matching *value. +func NewSelect(value *string, options ...Option) *Field { + f := &Field{kind: kindSelect, text: value, options: options} + for i, o := range options { + if o.Value == *value { + f.cursor = i + break } - f.groups[f.index].active = true - return f, f.groups[f.index].Init() } - - return f, group.Update(msg) -} - -// View implements [tea.Model]. -func (f *Form) View() tea.View { - var v tea.View - v.ReportFocus = true - v.SetContent(f.render()) - return v -} - -func (f *Form) render() string { - if f.quitting { - return "" + if len(options) > 0 { + *value = options[f.cursor].Value } - return lipgloss.NewStyle().Render(f.groups[f.index].View()) + return f } -// Group is a page of fields shown together. -type Group struct { - fields []Field - index int - title string - - viewport viewport.Model - width int - height int - active bool +// NewConfirm asks a yes/no question, starting from *value. +func NewConfirm(value *bool) *Field { + return &Field{kind: kindConfirm, yes: value} } -// NewGroup returns a group of the given fields. -func NewGroup(fields ...Field) *Group { - g := &Group{fields: fields, width: 80} - g.height = g.rawHeight() - g.viewport = viewport.New(viewport.WithWidth(g.width), viewport.WithHeight(g.height)) - return g -} +// Title sets the question. +func (f *Field) Title(title string) *Field { f.title = title; return f } -// Title sets the heading shown above the group's fields. -func (g *Group) Title(title string) *Group { - g.title = title - return g -} +// Description sets the hint shown under the question. +func (f *Field) Description(description string) *Field { f.description = description; return f } -func (g *Group) setWidth(width int) { - g.width = width - g.viewport.SetWidth(width) - for _, field := range g.fields { - field.setWidth(width) - } +// Placeholder sets the text shown while an input is empty. +func (f *Field) Placeholder(placeholder string) *Field { + f.input.Placeholder = placeholder + return f } -func (g *Group) setHeight(height int) { - g.height = height - h := height - g.titleFooterHeight() - g.viewport.SetHeight(h) - for _, field := range g.fields { - // A field height must not exceed the form height. - if h < lipgloss.Height(field.View()) { - field.setHeight(h) - } - } +// Password hides what is typed into an input. +func (f *Field) Password() *Field { + f.secret = true + f.input.EchoMode = textinput.EchoPassword + return f } -// Errors returns the validation errors of the group's fields. -func (g *Group) Errors() []error { - var errs []error - for _, field := range g.fields { - if err := field.Error(); err != nil { - errs = append(errs, err) - } - } - return errs +// Validate rejects answers before the form moves past the field. +func (f *Field) Validate(validate func(string) error) *Field { + f.validate = validate + return f } -// Init focuses the current field when the group is active. -func (g *Group) Init() tea.Cmd { - var cmd tea.Cmd - if g.active { - cmd = g.fields[g.index].Focus() +func (f *Field) check() error { + if f.validate == nil || f.text == nil { + return nil } - g.buildView() - return cmd + return f.validate(*f.text) } -func (g *Group) nextField() []tea.Cmd { - blur := g.fields[g.index].Blur() - if g.index == len(g.fields)-1 { - return []tea.Cmd{blur, nextGroup} +func (f *Field) focus() tea.Cmd { + switch f.kind { + case kindInput: + return f.input.Focus() + case kindText: + return f.area.Focus() } - g.index++ - return []tea.Cmd{blur, g.fields[g.index].Focus()} + return nil } -func (g *Group) prevField() []tea.Cmd { - blur := g.fields[g.index].Blur() - if g.index == 0 { - return []tea.Cmd{blur, prevGroup} - } - g.index-- - return []tea.Cmd{blur, g.fields[g.index].Focus()} +func (f *Field) blur() { + switch f.kind { + case kindInput: + f.input.Blur() + case kindText: + f.area.Blur() + } +} + +func (f *Field) setWidth(width int) { + inner := max(width-4, 10) + switch f.kind { + case kindInput: + f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) + case kindText: + f.area.SetWidth(inner) + } +} + +// update applies a message to the focused field. advance reports that the +// key also answered the question (y or n on a confirm). +func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { + key, isKey := msg.(tea.KeyPressMsg) + if isKey { + f.err = nil + } + switch f.kind { + case kindInput: + f.input, cmd = f.input.Update(msg) + *f.text = f.input.Value() + case kindText: + f.area, cmd = f.area.Update(msg) + *f.text = f.area.Value() + case kindSelect: + if !isKey || len(f.options) == 0 { + return nil, false + } + n := len(f.options) + switch key.String() { + case "up", "k", "ctrl+p": + f.cursor = (f.cursor - 1 + n) % n + case "down", "j", "ctrl+n": + f.cursor = (f.cursor + 1) % n + case "home", "g": + f.cursor = 0 + case "end", "G": + f.cursor = n - 1 + } + *f.text = f.options[f.cursor].Value + case kindConfirm: + if !isKey { + return nil, false + } + switch key.String() { + case "left", "right", "h", "l": + *f.yes = !*f.yes + case "y", "Y": + *f.yes = true + return nil, true + case "n", "N": + *f.yes = false + return nil, true + } + } + return cmd, false +} + +// Page is a set of fields shown together under an optional heading. +type Page struct { + title string + fields []*Field } -// Update routes key presses and pastes to the focused field and every other -// message to all fields. -func (g *Group) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - for i, field := range g.fields { - switch msg.(type) { - case tea.KeyPressMsg, tea.PasteMsg: - default: - m, cmd := field.Update(msg) - g.fields[i] = m - cmds = append(cmds, cmd) - } - if i == g.index { - m, cmd := field.Update(msg) - g.fields[i] = m - cmds = append(cmds, cmd) - } - } - - switch msg.(type) { - case nextFieldMsg: - cmds = append(cmds, g.nextField()...) - case prevFieldMsg: - cmds = append(cmds, g.prevField()...) - } - - g.buildView() - return tea.Batch(cmds...) +// NewPage groups fields onto one screen. +func NewPage(title string, fields ...*Field) *Page { + return &Page{title: title, fields: fields} } -func (g *Group) content() (int, string) { - var fields strings.Builder - offset := 0 - for i, field := range g.fields { - fields.WriteString(field.View()) - if i == g.index { - offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) - } - if i < len(g.fields)-1 { - fields.WriteString("\n\n") - } - } - return offset, fields.String() +// Form steps through pages of fields. enter and tab move forward, shift+tab +// moves back, and enter on the last field submits. +type Form struct { + pages []*Page + page int + field int + accessible bool + width int + height int + finished bool + aborted bool } -func (g *Group) buildView() { - offset, content := g.content() - g.viewport.SetContent(content) - g.viewport.SetYOffset(offset) +// New returns a form over pages. Dumb terminals default to line prompts. +func New(pages ...*Page) *Form { + f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb"} + f.resize(terminalWidth(), 0) + return f } -func (g *Group) header() string { - if g.title == "" { - return "" - } - return lipgloss.NewStyle().Render(wrap(g.title, g.width)) +// Accessible switches between the interactive form and line prompts. +func (f *Form) Accessible(on bool) *Form { + f.accessible = on + return f } -func (g *Group) footer() string { - var parts []string - for _, err := range g.Errors() { - parts = append(parts, wrap(focusedStyles.ErrorMessage.Render(err.Error()), g.width)) +func terminalWidth() int { + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return width } - return lipgloss.NewStyle().Render(strings.Join(parts, "\n")) + return 80 } -func (g *Group) titleFooterHeight() int { - h := 0 - if s := g.header(); s != "" { - h += lipgloss.Height(s) +// Run asks every question and returns once the form is submitted. Answers are +// written to the variables bound to each field. +func (f *Form) Run() error { + if f.empty() { + return nil } - if s := g.footer(); s != "" { - h += lipgloss.Height(s) + if f.accessible { + f.prompt(os.Stdout, os.Stdin) + return nil } - return h -} - -// rawHeight is the group's full height without a viewport. -func (g *Group) rawHeight() int { - _, content := g.content() - return lipgloss.Height(content) + g.titleFooterHeight() -} - -// View renders the group. -func (g *Group) View() string { - var parts []string - if s := g.header(); s != "" { - parts = append(parts, s) + if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + return fmt.Errorf("form: %w", err) } - parts = append(parts, g.viewport.View()) - if s := g.footer(); s != "" { - parts = append(parts, "", s) + if f.aborted { + return ErrUserAborted } - // A trailing space in the bottom-right cell scrolls some terminals. - last := len(parts) - 1 - parts[last] = strings.TrimSuffix(parts[last], " ") - return strings.Join(parts, "\n") -} - -func wrap(s string, limit int) string { - return lipgloss.Wrap(s, limit, ",.-; ") -} - -func noValidation(string) error { return nil } - -// Input is a single-line text field. -type Input struct { - value *string - title string - description string - textinput textinput.Model - validate func(string) error - err error - focused bool - width int - height int - nav navKeys -} - -// NewInput returns a single-line text field. -func NewInput() *Input { - return &Input{value: new(string), textinput: textinput.New(), validate: noValidation, nav: newNavKeys("enter", "tab")} -} - -// Value binds the field to value, which also supplies the initial text. -func (i *Input) Value(value *string) *Input { - i.value = value - i.textinput.SetValue(*value) - return i -} - -// Title sets the field title. -func (i *Input) Title(title string) *Input { i.title = title; return i } - -// Description sets the text shown under the title. -func (i *Input) Description(description string) *Input { i.description = description; return i } - -// Placeholder sets the text shown while the field is empty. -func (i *Input) Placeholder(placeholder string) *Input { - i.textinput.Placeholder = placeholder - return i -} - -// Password masks the typed characters. -func (i *Input) Password() *Input { - i.textinput.EchoMode = textinput.EchoPassword - return i -} - -// Validate sets a check that runs on submit and when the field loses focus. -func (i *Input) Validate(validate func(string) error) *Input { - i.validate = validate - return i -} - -func (i *Input) Error() error { return i.err } - -func (i *Input) Focus() tea.Cmd { - i.focused = true - return i.textinput.Focus() -} - -func (i *Input) Blur() tea.Cmd { - i.focused = false - *i.value = i.textinput.Value() - i.textinput.Blur() - i.err = i.validate(*i.value) - return nil -} - -func (i *Input) Init() tea.Cmd { - i.textinput.Blur() return nil } -func (i *Input) Update(msg tea.Msg) (Field, tea.Cmd) { - var cmds []tea.Cmd - if msg, ok := msg.(tea.KeyPressMsg); ok { - i.err = nil - switch { - case key.Matches(msg, i.nav.prev): - cmds = append(cmds, prevField) - case key.Matches(msg, i.nav.next): - if i.err = i.validate(i.textinput.Value()); i.err != nil { - return i, nil - } - cmds = append(cmds, nextField) +func (f *Form) empty() bool { + for _, p := range f.pages { + if len(p.fields) > 0 { + return false } } - var cmd tea.Cmd - i.textinput, cmd = i.textinput.Update(msg) - cmds = append(cmds, cmd) - *i.value = i.textinput.Value() - return i, tea.Batch(cmds...) + return true } -func (i *Input) View() string { - styles := stylesFor(i.focused) - maxWidth := i.width - styles.Base.GetHorizontalFrameSize() - - st := i.textinput.Styles() - st.Cursor.Color = styles.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.Text = styles.TextInput.Text - st.Focused.Placeholder = styles.TextInput.Placeholder - i.textinput.SetStyles(st) - - var sb strings.Builder - if i.title != "" { - sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth)) + "\n") - } - if i.description != "" { - sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth)) + "\n") - } - sb.WriteString(i.textinput.View()) - return styles.Base.Width(i.width).Height(i.height).Render(sb.String()) -} - -func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { - title := stylesFor(i.focused).Title.PaddingRight(1) - if i.textinput.EchoMode == textinput.EchoNormal { - *i.value = promptString(w, r, title.Render(cmp.Or(i.title, "Input:")), *i.value, i.validate) - return nil - } - fd, ok := r.(interface{ Fd() uintptr }) - if !ok { - return errors.New("password asking needs a tty") - } - value, err := promptPassword(w, fd.Fd(), title.Render(cmp.Or(i.title, "Password:")), i.validate) - if err != nil { - return err +func (f *Form) resize(width, height int) { + f.width, f.height = width, height + for _, p := range f.pages { + for _, field := range p.fields { + field.setWidth(width) + } } - *i.value = value - return nil -} - -func (i *Input) setWidth(width int) { - i.width = width - frame := stylesFor(i.focused).Base.GetHorizontalFrameSize() - prompt := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) - i.textinput.SetWidth(width - frame - prompt - 1) -} - -func (i *Input) setHeight(height int) { i.height = height } - -func (i *Input) setPosition(first, last bool) { i.nav.setPosition(first, last) } - -// Text is a multi-line text field; alt+enter or ctrl+j inserts a newline. -type Text struct { - value *string - title string - description string - textarea textarea.Model - validate func(string) error - err error - focused bool - width int - nav navKeys -} - -// NewText returns a multi-line text field. -func NewText() *Text { - ta := textarea.New() - ta.ShowLineNumbers = false - ta.Prompt = "" - st := ta.Styles() - st.Focused.CursorLine = lipgloss.NewStyle() - ta.SetStyles(st) - ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") - return &Text{value: new(string), textarea: ta, validate: noValidation, nav: newNavKeys("tab", "enter")} -} - -// Value binds the field to value, which also supplies the initial text. -func (t *Text) Value(value *string) *Text { - t.value = value - t.textarea.SetValue(*value) - return t } -// Title sets the field title. -func (t *Text) Title(title string) *Text { t.title = title; return t } - -// Description sets the text shown under the title. -func (t *Text) Description(description string) *Text { t.description = description; return t } - -// Validate sets a check that runs on submit and when the field loses focus. -func (t *Text) Validate(validate func(string) error) *Text { - t.validate = validate - return t -} - -func (t *Text) Error() error { return t.err } - -func (t *Text) Focus() tea.Cmd { - t.focused = true - return t.textarea.Focus() -} - -func (t *Text) Blur() tea.Cmd { - t.focused = false - *t.value = t.textarea.Value() - t.textarea.Blur() - t.err = t.validate(*t.value) - return nil +func (f *Form) current() *Field { + return f.pages[f.page].fields[f.field] } -func (t *Text) Init() tea.Cmd { - t.textarea.Blur() - return nil +func (f *Form) onLastField() bool { + return f.page == len(f.pages)-1 && f.field == len(f.pages[f.page].fields)-1 } -func (t *Text) Update(msg tea.Msg) (Field, tea.Cmd) { - var cmds []tea.Cmd - if msg, ok := msg.(tea.KeyPressMsg); ok { - t.err = nil +// step moves the focus by one field across page boundaries, skipping empty +// pages. It reports false when there is no field in that direction. +func (f *Form) step(delta int) bool { + page, field := f.page, f.field+delta + for { switch { - case key.Matches(msg, t.nav.next): - if t.err = t.validate(t.textarea.Value()); t.err != nil { - return t, nil + case field < 0: + page-- + if page < 0 { + return false } - cmds = append(cmds, nextField) - case key.Matches(msg, t.nav.prev): - if t.err = t.validate(t.textarea.Value()); t.err != nil { - return t, nil + field = len(f.pages[page].fields) - 1 + continue + case field >= len(f.pages[page].fields): + page++ + if page >= len(f.pages) { + return false } - cmds = append(cmds, prevField) - } - } - var cmd tea.Cmd - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - *t.value = t.textarea.Value() - return t, tea.Batch(cmds...) -} - -func (t *Text) View() string { - styles := stylesFor(t.focused) - st := t.textarea.Styles() - state := &st.Blurred - if t.focused { - state = &st.Focused - } - state.Placeholder = styles.TextInput.Placeholder - state.Text = styles.TextInput.Text - state.Prompt = styles.TextInput.Prompt - state.CursorLine = styles.TextInput.Text - st.Cursor.Color = styles.TextInput.Cursor.GetBackground() - t.textarea.SetStyles(st) - - maxWidth := t.width - styles.Base.GetHorizontalFrameSize() - var parts []string - if t.title != "" { - header := styles.Title.Render(wrap(t.title, maxWidth)) - if t.err != nil { - header += styles.ErrorIndicator.String() + field = 0 + continue } - parts = append(parts, header) + f.page, f.field = page, field + return true } - if t.description != "" { - parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) - } - parts = append(parts, t.textarea.View()) - return styles.Base.Render(strings.Join(parts, "\n")) -} - -func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { - prompt := stylesFor(t.focused).Title.PaddingRight(1).Render(cmp.Or(t.title, "Input:")) - *t.value = promptString(w, r, prompt, *t.value, t.validate) - return nil -} - -func (t *Text) setWidth(width int) { - t.width = width - t.textarea.SetWidth(width - stylesFor(t.focused).Base.GetHorizontalFrameSize()) } -func (t *Text) setHeight(height int) { - adjust := 0 - if t.title != "" { - adjust++ - } - if t.description != "" { - adjust++ +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + for f.field >= len(f.pages[f.page].fields) { + f.page++ } - t.textarea.SetHeight(height - stylesFor(t.focused).Base.GetVerticalFrameSize() - adjust) -} - -func (t *Text) setPosition(first, last bool) { t.nav.setPosition(first, last) } - -// Option is a choice in a [Select]: Key is shown, Value is stored. -type Option struct { - Key string - Value string -} - -// NewOption returns a select option. -func NewOption(key, value string) Option { - return Option{Key: key, Value: value} + return tea.Batch(f.current().focus(), tea.RequestWindowSize) } -var ( - selectUp = key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p")) - selectDown = key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n")) - selectTop = key.NewBinding(key.WithKeys("home", "g")) - selectBottom = key.NewBinding(key.WithKeys("end", "G")) - selectHalfUp = key.NewBinding(key.WithKeys("ctrl+u")) - selectHalfDown = key.NewBinding(key.WithKeys("ctrl+d")) -) - -// Select picks one option from a list. -type Select struct { - value *string - title string - description string - options []Option - selected int - focused bool - width int - height int - viewport viewport.Model - nav navKeys -} - -// NewSelect returns a single-choice list. -func NewSelect() *Select { - return &Select{value: new(string), nav: newNavKeys("enter", "tab")} -} - -// Value binds the field to value and moves the cursor to the matching option. -func (s *Select) Value(value *string) *Select { - s.value = value - for i, o := range s.options { - if o.Value == *value { - s.selected = i - break +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.resize(msg.Width, msg.Height) + return f, nil + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + f.aborted = true + return f, tea.Quit + case "shift+tab": + return f, f.move(-1) + case "tab": + if f.onLastField() { + return f, nil + } + return f, f.forward() + case "enter": + return f, f.forward() } } - s.updateValue() - return s -} - -// Title sets the field title. -func (s *Select) Title(title string) *Select { s.title = title; return s } - -// Description sets the text shown under the title. -func (s *Select) Description(description string) *Select { s.description = description; return s } - -// Options sets the choices. -func (s *Select) Options(options ...Option) *Select { - if len(options) == 0 { - return s + cmd, advance := f.current().update(msg) + if advance { + return f, tea.Batch(cmd, f.forward()) } - s.options = options - s.selectCurrent() - s.updateViewportSize() - s.updateValue() - return s + return f, cmd } -func (s *Select) selectCurrent() { - for i, o := range s.options { - if o.Value == *s.value { - s.selected = i - break - } - } - s.ensureCursorVisible() -} - -func (s *Select) Error() error { return nil } - -func (s *Select) Focus() tea.Cmd { - s.focused = true - return nil -} - -func (s *Select) Blur() tea.Cmd { - s.focused = false - return nil -} - -func (s *Select) Init() tea.Cmd { return nil } - -func (s *Select) Update(msg tea.Msg) (Field, tea.Cmd) { - s.updateViewportSize() - msgKey, ok := msg.(tea.KeyPressMsg) - if !ok { - return s, nil +// forward validates the focused field, then moves on or submits. +func (f *Form) forward() tea.Cmd { + cur := f.current() + if cur.err = cur.check(); cur.err != nil { + return nil } - last := len(s.options) - 1 - switch { - case key.Matches(msgKey, selectUp): - s.selected-- - if s.selected < 0 { - s.selected = last - s.viewport.GotoBottom() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msgKey, selectTop): - s.selected = 0 - s.viewport.GotoTop() - s.updateValue() - case key.Matches(msgKey, selectBottom): - s.selected = last - s.viewport.GotoBottom() - case key.Matches(msgKey, selectHalfUp): - s.selected = max(s.selected-s.viewport.Height()/2, 0) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msgKey, selectHalfDown): - s.selected = min(s.selected+s.viewport.Height()/2, last) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msgKey, selectDown): - s.selected++ - if s.selected > last { - s.selected = 0 - s.viewport.GotoTop() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msgKey, s.nav.prev): - if s.selected > last { - break - } - s.updateValue() - return s, prevField - case key.Matches(msgKey, s.nav.next): - if s.selected > last { - break - } - s.updateValue() - return s, nextField + if f.onLastField() { + f.finished = true + return tea.Quit } - s.ensureCursorVisible() - return s, nil + return f.move(1) } -func (s *Select) updateValue() { - if s.selected >= 0 && s.selected < len(s.options) { - *s.value = s.options[s.selected].Value +func (f *Form) move(delta int) tea.Cmd { + prev := f.current() + if !f.step(delta) { + return nil } + prev.blur() + return f.current().focus() } -func (s *Select) updateViewportSize() { - if s.height > 0 { - yoffset := 0 - if v := s.titleView(); v != "" { - yoffset += lipgloss.Height(v) - } - if v := s.descriptionView(); v != "" { - yoffset += lipgloss.Height(v) - } - s.viewport.SetHeight(max(1, s.height-yoffset)) - s.ensureCursorVisible() - } else { - s.viewport.SetHeight(lipgloss.Height(s.optionsView())) - } - if s.width > 0 { - s.viewport.SetWidth(s.width) - } else { - s.viewport.SetWidth(lipgloss.Width(s.optionsView())) +// View implements [tea.Model]. +func (f *Form) View() tea.View { + if f.finished || f.aborted { + return tea.NewView("") } + return tea.NewView(f.render()) } -func (s *Select) maxWidth() int { - return s.width - stylesFor(s.focused).Base.GetHorizontalFrameSize() -} - -func (s *Select) titleView() string { - return stylesFor(s.focused).Title.Render(wrap(s.title, s.maxWidth())) -} - -func (s *Select) descriptionView() string { - if s.description == "" { - return "" +func (f *Form) render() string { + page := f.pages[f.page] + var b strings.Builder + if page.title != "" { + b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(page.title) + "\n\n") } - return stylesFor(s.focused).Description.Render(wrap(s.description, s.maxWidth())) -} - -func (s *Select) optionsView() string { - var sb strings.Builder - for i, option := range s.options { - sb.WriteString(s.renderOption(option, i == s.selected)) - if i < len(s.options)-1 { - sb.WriteString("\n") + for i, field := range page.fields { + if i > 0 { + b.WriteString("\n\n") } + b.WriteString(f.renderField(field, i == f.field)) } - return sb.String() + return b.String() } -func (s *Select) renderOption(option Option, selected bool) string { - styles := stylesFor(s.focused) - cursor := styles.SelectSelector.String() - cursorWidth := lipgloss.Width(cursor) - label := wrap(option.Key, s.maxWidth()-cursorWidth) - if selected { - return lipgloss.JoinHorizontal(lipgloss.Left, cursor, styles.SelectedOption.Render(label)) +func (f *Form) renderField(field *Field, focused bool) string { + inner := max(f.width-4, 10) + bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) + if focused { + bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) + } + lines := []string{} + if field.title != "" { + lines = append(lines, title.Render(lipgloss.Wrap(field.title, inner, " "))) + } + if field.description != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(lipgloss.Wrap(field.description, inner, " "))) + } + switch field.kind { + case kindInput: + lines = append(lines, field.input.View()) + case kindText: + lines = append(lines, field.area.View()) + case kindSelect: + lines = append(lines, f.renderOptions(field, focused)) + case kindConfirm: + lines = append(lines, renderButtons(*field.yes, focused)) + } + if field.err != nil { + lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). + PaddingLeft(1). + Render(strings.Join(lines, "\n")) +} + +// renderOptions shows a window of options around the cursor that fits the +// terminal height. +func (f *Form) renderOptions(field *Field, focused bool) string { + rows := len(field.options) + if f.height > 0 { + rows = min(rows, max(3, f.height-8)) + } + start := min(max(field.cursor-rows/2, 0), len(field.options)-rows) + picked := lipgloss.NewStyle().Foreground(dimmed) + if focused { + picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - return lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", cursorWidth), styles.UnselectedOption.Render(label)) -} - -// ensureCursorVisible scrolls the viewport the minimum amount to show the -// selected option. -func (s *Select) ensureCursorVisible() { - offset, height := 0, 0 - for i, option := range s.options { - h := lipgloss.Height(s.renderOption(option, i == s.selected)) - if i == s.selected { - height = h - break + var lines []string + for i := start; i < start+rows; i++ { + if i == field.cursor { + lines = append(lines, picked.Render("> "+field.options[i].Label)) + } else { + lines = append(lines, " "+field.options[i].Label) } - offset += h - } - if height <= 0 { - return } - if y := s.viewport.YOffset(); offset < y { - s.viewport.ScrollUp(y - offset) - } else if end := y + s.viewport.Height(); offset+height > end { - s.viewport.ScrollDown(offset + height - end) - } -} - -func (s *Select) View() string { - s.viewport.SetContent(s.optionsView()) - var parts []string - if s.title != "" { - parts = append(parts, s.titleView()) + if rows < len(field.options) { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(field.options)))) } - if s.description != "" { - parts = append(parts, s.descriptionView()) - } - parts = append(parts, s.viewport.View()) - return stylesFor(s.focused).Base.Width(s.width).Height(s.height).Render(strings.Join(parts, "\n")) -} - -func (s *Select) RunAccessible(w io.Writer, r io.Reader) error { - fmt.Fprintln(w, stylesFor(s.focused).Title.PaddingRight(1).Render(cmp.Or(s.title, "Select:"))) - for i, option := range s.options { - fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) - } - s.selectCurrent() - prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) - if len(s.options) == 1 { - prompt = "There is only one option available; enter the number 1:" - } - choice := promptInt(w, r, prompt, 1, len(s.options), s.selected+1) - *s.value = s.options[choice-1].Value - return nil -} - -func (s *Select) setWidth(width int) { - s.width = width - s.updateViewportSize() -} - -func (s *Select) setHeight(height int) { - s.height = height - s.updateViewportSize() -} - -func (s *Select) setPosition(first, last bool) { s.nav.setPosition(first, last) } - -var ( - confirmToggle = key.NewBinding(key.WithKeys("h", "l", "right", "left")) - confirmAccept = key.NewBinding(key.WithKeys("y", "Y")) - confirmReject = key.NewBinding(key.WithKeys("n", "N")) -) - -// Confirm is a yes/no choice. -type Confirm struct { - value *bool - title string - description string - focused bool - width int - height int - nav navKeys -} - -// NewConfirm returns a yes/no choice. -func NewConfirm() *Confirm { - return &Confirm{value: new(bool), nav: newNavKeys("enter", "tab")} + return strings.Join(lines, "\n") } -// Value binds the field to value, which also supplies the initial choice. -func (c *Confirm) Value(value *bool) *Confirm { c.value = value; return c } - -// Title sets the field title. -func (c *Confirm) Title(title string) *Confirm { c.title = title; return c } - -// Description sets the text shown under the title. -func (c *Confirm) Description(description string) *Confirm { c.description = description; return c } - -func (c *Confirm) Error() error { return nil } - -func (c *Confirm) Focus() tea.Cmd { - c.focused = true - return nil -} - -func (c *Confirm) Blur() tea.Cmd { - c.focused = false - return nil -} - -func (c *Confirm) Init() tea.Cmd { return nil } - -func (c *Confirm) Update(msg tea.Msg) (Field, tea.Cmd) { - msgKey, ok := msg.(tea.KeyPressMsg) - if !ok { - return c, nil +func renderButtons(yes, focused bool) string { + on := lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) + if focused { + on = on.Background(accent) } - switch { - case key.Matches(msgKey, confirmToggle): - *c.value = !*c.value - case key.Matches(msgKey, c.nav.prev): - return c, prevField - case key.Matches(msgKey, c.nav.next): - return c, nextField - case key.Matches(msgKey, confirmAccept): - *c.value = true - return c, nextField - case key.Matches(msgKey, confirmReject): - *c.value = false - return c, nextField + off := lipgloss.NewStyle().Padding(0, 2).Foreground(dimmed) + if yes { + return on.Render("Yes") + " " + off.Render("No") } - return c, nil + return off.Render("Yes") + " " + on.Render("No") } -func (c *Confirm) View() string { - styles := stylesFor(c.focused) - maxWidth := c.width - styles.Base.GetHorizontalFrameSize() - - var sb strings.Builder - if c.title != "" { - sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) - } - if c.description != "" { - sb.WriteString("\n" + styles.Description.Render(wrap(c.description, maxWidth))) - } - if c.title != "" || c.description != "" { - sb.WriteString("\n\n") - } - - yes, no := styles.BlurredButton.Render("Yes"), styles.FocusedButton.Render("No") - if *c.value { - yes, no = styles.FocusedButton.Render("Yes"), styles.BlurredButton.Render("No") +// prompt asks every question as a plain line prompt. Blank answers keep the +// current value; end of input leaves the remaining answers unchanged. +func (f *Form) prompt(w io.Writer, r io.Reader) { + in := bufio.NewReader(r) + for _, page := range f.pages { + for _, field := range page.fields { + if !field.ask(w, r, in) { + fmt.Fprintln(w) + return + } + fmt.Fprintln(w) + } } - buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, no) - width := max(lipgloss.Width(buttons), lipgloss.Width(sb.String())) - sb.WriteString(lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(buttons)) - return styles.Base.Width(c.width).Height(c.height).Render(sb.String()) } -func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { - opts := "[y/N]" - if *c.value { - opts = "[Y/n]" +// ask prompts for one field until the answer is valid. It reports false at +// end of input. +func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { + if f.title != "" { + fmt.Fprintln(w, f.title) } - prompt := stylesFor(c.focused).Title.PaddingRight(1).Render(cmp.Or(c.title, "Choose"), opts) - *c.value = promptBool(w, r, prompt, *c.value) - return nil -} - -func (c *Confirm) setWidth(width int) { c.width = width } - -func (c *Confirm) setHeight(height int) { c.height = height } - -func (c *Confirm) setPosition(first, last bool) { c.nav.setPosition(first, last) } - -type fieldStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - ErrorIndicator lipgloss.Style - ErrorMessage lipgloss.Style - - SelectSelector lipgloss.Style - SelectedOption lipgloss.Style - UnselectedOption lipgloss.Style - - FocusedButton lipgloss.Style - BlurredButton lipgloss.Style - - TextInput struct { - Cursor lipgloss.Style - Placeholder lipgloss.Style - Prompt lipgloss.Style - Text lipgloss.Style + if f.description != "" { + fmt.Fprintln(w, f.description) } -} - -var focusedStyles, blurredStyles = newStyles() - -func newStyles() (focused, blurred fieldStyles) { - accent := {{themeAccent}} - dimmed := {{themeDimmed}} - subtle := {{themeSubtle}} - errColor := {{themeError}} - - button := lipgloss.NewStyle().Padding(0, 2).MarginRight(1) - - focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) - focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") - focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") - focused.SelectSelector = lipgloss.NewStyle().SetString("> ") - focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) - focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) - focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - blurred = focused - blurred.Base = blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - - focused.Base = focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - focused.Title = focused.Title.Foreground(accent).Bold(true) - focused.Description = focused.Description.Foreground(dimmed).Italic(true) - focused.ErrorIndicator = focused.ErrorIndicator.Foreground(errColor) - focused.ErrorMessage = focused.ErrorMessage.Foreground(errColor) - focused.SelectSelector = focused.SelectSelector.Foreground(accent).SetString("> ") - focused.SelectedOption = focused.SelectedOption.Foreground(accent).Bold(true) - focused.FocusedButton = focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - focused.BlurredButton = focused.BlurredButton.Background(subtle) - focused.TextInput.Cursor = focused.TextInput.Cursor.Foreground(accent) - focused.TextInput.Placeholder = focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - focused.TextInput.Prompt = focused.TextInput.Prompt.Foreground(accent) - - blurred.Base = blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - blurred.Title = blurred.Title.Foreground(dimmed) - blurred.Description = blurred.Description.Foreground(subtle).Italic(true) - blurred.TextInput.Text = blurred.TextInput.Text.Foreground(dimmed) - blurred.TextInput.Placeholder = blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - blurred.SelectedOption = blurred.SelectedOption.Foreground(dimmed) - blurred.SelectSelector = blurred.SelectSelector.Foreground(dimmed) - return focused, blurred -} - -func stylesFor(focused bool) *fieldStyles { - if focused { - return &focusedStyles + switch f.kind { + case kindSelect: + for i, o := range f.options { + fmt.Fprintf(w, " %d. %s\n", i+1, o.Label) + } } - return &blurredStyles -} - -// promptString prints prompt and reads a line until validate accepts it. A -// blank answer, or end of input, yields defaultValue. -func promptString(out io.Writer, in io.Reader, prompt, defaultValue string, validate func(string) error) string { - scanner := bufio.NewScanner(in) - var input string for { - fmt.Fprint(out, prompt) - if !scanner.Scan() { - fmt.Fprintln(out) - break + switch f.kind { + case kindSelect: + fmt.Fprintf(w, "Choose 1-%d [%d]: ", len(f.options), f.cursor+1) + case kindConfirm: + if *f.yes { + fmt.Fprint(w, "[Y/n]: ") + } else { + fmt.Fprint(w, "[y/N]: ") + } + default: + fmt.Fprint(w, "> ") + } + line, ok := readAnswer(w, raw, in, f.secret) + if !ok { + return false } - input = scanner.Text() - if err := validate(input); err != nil { - fmt.Fprintln(out, err) + if msg := f.answer(line); msg != "" { + fmt.Fprintln(w, msg) continue } - break + return true } - return cmp.Or(strings.TrimSpace(input), defaultValue) } -// promptInt reads a number in [low, high]; a blank answer picks defaultValue. -func promptInt(out io.Writer, in io.Reader, prompt string, low, high, defaultValue int) int { - input := promptString(out, in, prompt, strconv.Itoa(defaultValue), func(s string) error { - if strings.TrimSpace(s) == "" { - return nil +// answer applies a typed line, returning a message when it must be re-asked. +func (f *Field) answer(line string) string { + line = strings.TrimSpace(line) + switch f.kind { + case kindSelect: + if line == "" { + return "" } - if i, err := strconv.Atoi(s); err == nil && i >= low && i <= high { - return nil + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) } - if low == high { - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be %d", low) + f.cursor = n - 1 + *f.text = f.options[f.cursor].Value + case kindConfirm: + switch strings.ToLower(line) { + case "": + case "y", "yes": + *f.yes = true + case "n", "no": + *f.yes = false + default: + return "Answer y or n." } - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) - }) - choice, _ := strconv.Atoi(input) - return choice -} - -func parseBool(s string) (bool, error) { - switch strings.ToLower(s) { - case "y", "yes": - return true, nil - case "n", "no": - return false, nil + default: + value := *f.text + if line != "" { + value = line + } + if f.validate != nil { + if err := f.validate(value); err != nil { + return err.Error() + } + } + *f.text = value } - return false, errors.New("invalid input. please try again") + return "" } -// promptBool reads y/yes/n/no; a blank answer keeps defaultValue. -func promptBool(out io.Writer, in io.Reader, prompt string, defaultValue bool) bool { - def := "N" - if defaultValue { - def = "y" +// readAnswer reads one line, without echo for secrets typed at a terminal. +func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, bool) { + if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { + b, err := term.ReadPassword(int(file.Fd())) + fmt.Fprintln(w) + return string(b), err == nil } - input := promptString(out, in, prompt, def, func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - _, err := parseBool(s) - return err - }) - b, _ := parseBool(input) - return b -} - -// promptPassword reads a line from the terminal fd without echoing it. -func promptPassword(out io.Writer, fd uintptr, prompt string, validate func(string) error) (string, error) { - for { - fmt.Fprint(out, prompt) - pwd, err := term.ReadPassword(int(fd)) - if err != nil { - return "", err - } - fmt.Fprintln(out) - if err := validate(string(pwd)); err != nil { - fmt.Fprintln(out, err) - continue - } - return string(pwd), nil + line, err := in.ReadString('\n') + if err != nil && line == "" { + return "", false } + return strings.TrimRight(line, "\r\n"), true } {{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl new file mode 100644 index 000000000..72782663c --- /dev/null +++ b/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl @@ -0,0 +1,283 @@ +{{- if isAnyInteractiveEnabled}} +package forms + +import ( + "bytes" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func press(f *Form, keys ...string) tea.Cmd { + var last tea.Cmd + for _, k := range keys { + var msg tea.KeyPressMsg + switch k { + case "enter": + msg = tea.KeyPressMsg{Code: tea.KeyEnter} + case "tab": + msg = tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + msg = tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + case "up": + msg = tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + msg = tea.KeyPressMsg{Code: tea.KeyDown} + case "left": + msg = tea.KeyPressMsg{Code: tea.KeyLeft} + case "backspace": + msg = tea.KeyPressMsg{Code: tea.KeyBackspace} + case "ctrl+c": + msg = tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + case "ctrl+j": + msg = tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} + default: + for _, r := range k { + _, last = f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + continue + } + _, last = f.Update(msg) + } + return last +} + +func isQuit(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +func required(v string) error { + if strings.TrimSpace(v) == "" { + return errors.New("required") + } + return nil +} + +func TestFormStepsThroughPagesAndSubmits(t *testing.T) { + var token, user, format string + f := New( + NewPage("Authentication", + NewInput(&token).Title("Token").Password(), + NewInput(&user).Title("User").Placeholder("alice"), + ), + NewPage("Preferences", + NewSelect(&format, NewOption("Keep current", ""), NewOption("json", "json"), NewOption("yaml", "yaml")).Title("Format"), + ), + ) + f.Init() + + press(f, "secret", "tab", "bob", "shift+tab", "!", "enter", "enter") + if f.page != 1 { + t.Fatalf("expected the second page, got page %d", f.page) + } + if !strings.Contains(f.render(), "Preferences") || strings.Contains(f.render(), "Authentication") { + t.Fatalf("expected only the second page to render:\n%s", f.render()) + } + press(f, "down", "down", "down", "up") + if !isQuit(press(f, "enter")) { + t.Fatal("enter on the last field should submit") + } + if token != "secret!" || user != "bob" || format != "yaml" { + t.Fatalf("got token=%q user=%q format=%q", token, user, format) + } + if strings.Contains(f.render(), "secret") { + t.Fatal("password input must not render its value") + } +} + +func TestTabDoesNotSubmitFromLastField(t *testing.T) { + var v string + f := New(NewPage("", NewInput(&v))) + f.Init() + if press(f, "tab") != nil || f.finished { + t.Fatal("tab on the last field should do nothing") + } +} + +func TestValidationBlocksMovingForward(t *testing.T) { + var a, b string + f := New(NewPage("", NewInput(&a).Title("A").Validate(required), NewInput(&b).Title("B"))) + f.Init() + + press(f, "enter") + if f.field != 0 || !strings.Contains(f.render(), "required") { + t.Fatalf("expected to stay on A with an error, field=%d\n%s", f.field, f.render()) + } + press(f, "x") + if strings.Contains(f.render(), "required") { + t.Fatal("typing should clear the error") + } + press(f, "enter") + if f.field != 1 { + t.Fatalf("expected to move to B, field=%d", f.field) + } + press(f, "shift+tab") + if f.field != 0 { + t.Fatal("shift+tab should move back without validating") + } +} + +func TestTextInsertsNewlinesAndEnterMovesOn(t *testing.T) { + var body string + f := New(NewPage("", NewText(&body).Validate(required))) + f.Init() + press(f, `{"a":`, "ctrl+j", "1}") + if !isQuit(press(f, "enter")) { + t.Fatal("enter should submit the text field") + } + if body != "{\"a\":\n1}" { + t.Fatalf("got %q", body) + } +} + +func TestConfirmKeys(t *testing.T) { + var yes bool + f := New(NewPage("", NewConfirm(&yes).Title("Continue?"))) + f.Init() + press(f, "left") + if !yes { + t.Fatal("left should toggle") + } + if !isQuit(press(f, "n")) || yes { + t.Fatal("n should answer no and submit") + } + yes = false + f = New(NewPage("", NewConfirm(&yes))) + f.Init() + if !isQuit(press(f, "y")) || !yes { + t.Fatal("y should answer yes and submit") + } +} + +func TestSelectStartsAtCurrentValueAndWraps(t *testing.T) { + v := "b" + f := New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b"), NewOption("C", "c")))) + f.Init() + press(f, "down", "down") + if v != "a" { + t.Fatalf("expected the cursor to wrap to a, got %q", v) + } +} + +func TestSelectWindowFitsTerminalHeight(t *testing.T) { + var v string + options := make([]Option, 40) + for i := range options { + options[i] = NewOption("option-"+string(rune('A'+i%26)), string(rune('a'+i%26))) + } + f := New(NewPage("", NewSelect(&v, options...))) + f.Init() + f.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + if lines := strings.Count(f.render(), "\n") + 1; lines > 12 { + t.Fatalf("rendered %d lines for a 12-line terminal", lines) + } +} + +func TestCtrlCAborts(t *testing.T) { + var v string + f := New(NewPage("", NewInput(&v))) + f.Init() + if !isQuit(press(f, "ctrl+c")) || !f.aborted { + t.Fatal("ctrl+c should abort") + } +} + +func TestPrompts(t *testing.T) { + tests := []struct { + name string + input string + build func() (*Form, func() string) + want string + out []string + }{ + { + name: "input keeps current value on blank answer", + input: "\n", + build: func() (*Form, func() string) { + v := "kept" + return New(NewPage("", NewInput(&v).Title("Name"))), func() string { return v } + }, + want: "kept", + out: []string{"Name\n> "}, + }, + { + name: "input re-asks until valid", + input: "\n \nok\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewInput(&v).Validate(required))), func() string { return v } + }, + want: "ok", + out: []string{"required\n> required\n> "}, + }, + { + name: "secret read from a pipe is not echoed", + input: "s3cret\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewInput(&v).Title("Token").Password())), func() string { return v } + }, + want: "s3cret", + }, + { + name: "select by number", + input: "9\n2\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b")).Title("Pick"))), func() string { return v } + }, + want: "b", + out: []string{"Pick\n 1. A\n 2. B\nChoose 1-2 [1]: Enter a number from 1 to 2.\n"}, + }, + { + name: "confirm", + input: "maybe\nyes\n", + build: func() (*Form, func() string) { + var v bool + return New(NewPage("", NewConfirm(&v).Title("Sure?"))), func() string { + if v { + return "yes" + } + return "no" + } + }, + want: "yes", + out: []string{"[y/N]: Answer y or n.\n"}, + }, + { + name: "end of input stops asking", + input: "first\n", + build: func() (*Form, func() string) { + var a, b string + f := New(NewPage("", NewInput(&a).Title("A"), NewInput(&b).Title("B").Validate(required))) + return f, func() string { return a + "|" + b } + }, + want: "first|", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, got := tt.build() + var out bytes.Buffer + f.prompt(&out, strings.NewReader(tt.input)) + if got() != tt.want { + t.Fatalf("got %q, want %q", got(), tt.want) + } + for _, s := range tt.out { + if !strings.Contains(out.String(), s) { + t.Fatalf("output %q does not contain %q", out.String(), s) + } + } + if strings.Contains(out.String(), "s3cret") { + t.Fatal("secret was echoed") + } + }) + } +} +{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index d08f9a8ea..3fb5c9e89 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -687,9 +687,9 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). + form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))) + Description(strings.Join(names, ", ")))) if err := form.Run(); err != nil { return nil, err } @@ -717,28 +717,23 @@ func runPromptField(field PromptField) (PromptAnswer, error) { return runRepeatableFlagPrompt(field) } - var answer PromptAnswer - var formField forms.Field + ask := func(f *forms.Field) error { + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).Run() + } + switch field.Kind { case "bool": if field.Required { value := false - formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) - answer.Set = true - answer.Values = []string{strconv.FormatBool(value)} - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + if err := ask(forms.NewConfirm(&value)); err != nil { return PromptAnswer{}, err } - answer.Values[0] = strconv.FormatBool(value) - return answer, nil + return PromptAnswer{Set: true, Values: []string{strconv.FormatBool(value)}}, nil } value := "" - formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options( + if err := ask(forms.NewSelect(&value, forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), - ).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + )); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil @@ -752,79 +747,30 @@ func runPromptField(field PromptField) (PromptAnswer, error) { for _, option := range field.Options { options = append(options, forms.NewOption(option, option)) } - formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, options...)); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil } value := "" - description := field.Summary - var validate func(string) error - if field.Required { - validate = func(value string) error { - if strings.TrimSpace(value) == "" { + var formField *forms.Field + if field.Kind == "json" { + formField = forms.NewText(&value) + field.Summary += " (JSON)" + } else { + formField = forms.NewInput(&value) + } + formField.Validate(func(value string) error { + if strings.TrimSpace(value) == "" { + if field.Required { return fmt.Errorf("required") } return nil } - } - switch field.Kind { - case "json": - formField = forms.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) - if validate != nil { - formField = formField.(*forms.Text).Validate(validate) - } - case "int64": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseInt(value, 10, 64); err != nil { - return fmt.Errorf("must be an integer") - } - return nil - }) - case "float64": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseFloat(value, 64); err != nil { - return fmt.Errorf("must be a number") - } - return nil - }) - case "duration": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := time.ParseDuration(value); err != nil { - return fmt.Errorf("must be a duration") - } - return nil - }) - default: - input := forms.NewInput().Title(field.Name).Description(description).Value(&value) - if validate != nil { - input = input.Validate(validate) - } - formField = input - } - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + return checkPromptValue(field.Kind, value) + }) + if err := ask(formField); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { @@ -833,6 +779,25 @@ func runPromptField(field PromptField) (PromptAnswer, error) { return PromptAnswer{Set: true, Values: []string{value}}, nil } +// checkPromptValue rejects a non-empty answer that does not parse as kind. +func checkPromptValue(kind, value string) error { + switch kind { + case "int64": + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + return fmt.Errorf("must be an integer") + } + case "float64": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return fmt.Errorf("must be a number") + } + case "duration": + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("must be a duration") + } + } + return nil +} + func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { @@ -843,7 +808,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := forms.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput(&value).Title(title).Description(description) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -852,8 +817,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { return nil }) } - form := forms.NewForm(forms.NewGroup(input)) - if err := form.Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index 8590c1058..9d0cfd2ca 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -75,33 +75,33 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { {{- end}} accessible := formMode == interactive.FormAccessible - var groups []*forms.Group + var pages []*forms.Page {{- if hasGlobalSecurity}} - securityFields := []forms.Field{ + securityFields := []*forms.Field{ {{templateAuthLoginFormFields | indent 12 | trim}} } - groups = append(groups, forms.NewGroup(securityFields...).Title("Authentication")) + pages = append(pages, forms.NewPage("Authentication", securityFields...)) {{- end}} {{- if hasGlobals}} - globalFields := []forms.Field{ + globalFields := []*forms.Field{ {{templateConfigureGlobalFormFields | indent 12 | trim}} } - groups = append(groups, forms.NewGroup(globalFields...).Title("Global Parameters")) + pages = append(pages, forms.NewPage("Global Parameters", globalFields...)) {{- end}} - // Preference fields use forms.Select which loops forever on EOF in - // accessible mode (non-TTY). Only show them when truly interactive. + // Preferences are only offered in the interactive form; line prompts + // cover credentials and global parameters. {{templateConfigurePreferenceVarDeclarations | indent 8 | trim}} if !accessible { - preferenceFields := []forms.Field{ + preferenceFields := []*forms.Field{ {{templateConfigurePreferenceFormFields | indent 16 | trim}} } - groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) + pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := forms.NewForm(groups...).WithAccessible(accessible) + form := forms.New(pages...).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index a37e7850e..55516a612 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2152,23 +2152,21 @@ registerTemplateFunc( function templateConfigurePreferenceFormFields(): string { const lines: string[] = []; - lines.push(`forms.NewSelect().`); - lines.push(` Title("Default output format").`); + lines.push(`forms.NewSelect(&cfgOutputFormat,`); + lines.push(` forms.NewOption("Keep current", ""),`); lines.push( - ` Description("Choose the default response rendering format for this CLI").`, + ` forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, ); - lines.push(` Options(`); - lines.push(` forms.NewOption("Keep current", ""),`); + lines.push(` forms.NewOption("pretty", "pretty"),`); + lines.push(` forms.NewOption("json", "json"),`); + lines.push(` forms.NewOption("yaml", "yaml"),`); + lines.push(` forms.NewOption("table", "table"),`); + lines.push(` forms.NewOption("toon", "toon"),`); + lines.push(`).`); + lines.push(` Title("Default output format").`); lines.push( - ` forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"),`, + ` Description("Choose the default response rendering format for this CLI"),`, ); - lines.push(` forms.NewOption("pretty", "pretty"),`); - lines.push(` forms.NewOption("json", "json"),`); - lines.push(` forms.NewOption("yaml", "yaml"),`); - lines.push(` forms.NewOption("table", "table"),`); - lines.push(` forms.NewOption("toon", "toon"),`); - lines.push(` ).`); - lines.push(` Value(&cfgOutputFormat),`); return lines.join("\n "); } registerTemplateFunc( @@ -2303,70 +2301,10 @@ registerTemplateFunc( ); /** - * Generate form field construction for the auth login form. - * Each security field is mapped to the appropriate form component: - * - Secrets → forms.NewInput().Password() - * - Non-secrets → forms.NewInput() - * - Arrays → forms.NewInput() with comma-separated hint + * Generate form fields for every CLI security field (configure command). */ function templateAuthLoginFormFields(): string { - const fields = getCLISecurityFields(); - if (fields.length === 0) return ""; - - const acc = securityConfigAccessor(); - const lines: string[] = []; - - for (const field of fields) { - const varName = `auth${caser().ToPascal(field.field.Name)}`; - const title = escapeGoString(field.description); - const fieldName = caser().ToPascal(field.field.Name); - const cfgExpr = `${acc}${fieldName}`; - - if (field.isArray) { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - "(comma-separated)", - )}").`, - ); - lines.push(` Value(&${varName}),`); - } else if (field.isSecret) { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push(` Password().`); - lines.push( - ` Placeholder(maskSecret(${buildFormPlaceholder( - `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, - field.field, - )})).`, - ); - lines.push(` Value(&${varName}),`); - } else { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push( - ` Placeholder(${buildFormPlaceholder(cfgExpr, field.field)}).`, - ); - lines.push(` Value(&${varName}),`); - } - } - - return lines.join("\n "); + return genSchemeFormFields(getCLISecurityFields()).join("\n "); } registerTemplateFunc( "templateAuthLoginFormFields", @@ -2596,7 +2534,7 @@ function templateAuthLoginBody(): string { lines.push(`// #region custom-auth-vars`); lines.push(`// #endregion custom-auth-vars`); lines.push(``); - lines.push(`fields := []forms.Field{`); + lines.push(`fields := []*forms.Field{`); lines.push(...genSchemeFormFields(group.fields)); lines.push(`}`); lines.push(``); @@ -2604,7 +2542,7 @@ function templateAuthLoginBody(): string { lines.push(`// #endregion custom-auth-groups`); lines.push(``); lines.push( - `form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible)`, + `form := forms.New(forms.NewPage("", fields...)).Accessible(accessible)`, ); lines.push(``); lines.push(`if err := form.Run(); err != nil {`); @@ -2617,22 +2555,20 @@ function templateAuthLoginBody(): string { lines.push(`accessible := formMode == interactive.FormAccessible`); lines.push(``); lines.push(`var selectedScheme string`); - lines.push(`schemeSelect := forms.NewSelect().`); - lines.push(` Title("Authentication Method").`); - lines.push(` Description("Choose which credentials to configure").`); - lines.push(` Options(`); + lines.push(`schemeSelect := forms.NewSelect(&selectedScheme,`); for (const group of groups) { lines.push( - ` forms.NewOption("${escapeGoString(group.label)}", "${ + ` forms.NewOption("${escapeGoString(group.label)}", "${ group.key }"),`, ); } - lines.push(` ).`); - lines.push(` Value(&selectedScheme)`); + lines.push(`).`); + lines.push(` Title("Authentication Method").`); + lines.push(` Description("Choose which credentials to configure")`); lines.push(``); lines.push( - `if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).Run(); err != nil {`, + `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2646,14 +2582,14 @@ function templateAuthLoginBody(): string { lines.push(`case "${group.key}":`); lines.push(...genSchemeVarDecls(group.fields).map((l) => ` ${l}`)); lines.push(``); - lines.push(` fields := []forms.Field{`); + lines.push(` fields := []*forms.Field{`); lines.push( ...genSchemeFormFields(group.fields).map((l) => ` ${l}`), ); lines.push(` }`); lines.push(``); lines.push( - ` form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible)`, + ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible)`, ); lines.push(``); lines.push(` if err := form.Run(); err != nil {`); @@ -2847,59 +2783,51 @@ function buildFormPlaceholder(configExpr: string, field: FieldDef): string { return configExpr; } -/** Generate form field construction for a set of scheme fields */ +/** + * Render a Go builder chain as one composite-literal element: + * `head.\n call1.\n call2,`. + */ +function goChain(head: string, calls: string[]): string[] { + return [ + `${head}.`, + ...calls.map((c, i) => ` ${c}${i === calls.length - 1 ? "," : "."}`), + ]; +} + +/** Generate form fields for a set of scheme fields */ function genSchemeFormFields(fields: CLISecurityFieldInfo[]): string[] { const acc = securityConfigAccessor(); const lines: string[] = []; for (const field of fields) { const varName = `auth${caser().ToPascal(field.field.Name)}`; - const title = escapeGoString(field.description); - const fieldName = caser().ToPascal(field.field.Name); - const cfgExpr = `${acc}${fieldName}`; + const cfgExpr = `${acc}${caser().ToPascal(field.field.Name)}`; + const calls = [`Title("${escapeGoString(field.description)}")`]; if (field.isArray) { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( + calls.push( + `Description("${buildFormDescription( field.flagName, field.field, "(comma-separated)", - )}").`, + )}")`, ); - lines.push(` Value(&${varName}),`); } else if (field.isSecret) { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, - ); - lines.push(` Password().`); - lines.push( - ` Placeholder(maskSecret(${buildFormPlaceholder( + calls.push( + `Description("${buildFormDescription(field.flagName, field.field)}")`, + `Password()`, + `Placeholder(maskSecret(${buildFormPlaceholder( `config.GetStoredSecret("${field.flagName}", ${cfgExpr})`, field.field, - )})).`, + )}))`, ); - lines.push(` Value(&${varName}),`); } else { - lines.push(`forms.NewInput().`); - lines.push(` Title("${title}").`); - lines.push( - ` Description("${buildFormDescription( - field.flagName, - field.field, - )}").`, + calls.push( + `Description("${buildFormDescription(field.flagName, field.field)}")`, + `Placeholder(${buildFormPlaceholder(cfgExpr, field.field)})`, ); - lines.push( - ` Placeholder(${buildFormPlaceholder(cfgExpr, field.field)}).`, - ); - lines.push(` Value(&${varName}),`); } + lines.push(...goChain(`forms.NewInput(&${varName})`, calls)); } return lines; @@ -2992,11 +2920,13 @@ function templateConfigureGlobalFormFields(): string { `Global ${sanitizeFlagName(field.Name)} parameter`; const flagName = sanitizeFlagName(field.Name); - lines.push(`forms.NewInput().`); - lines.push(` Title("${escapeGoString(desc)}").`); - lines.push(` Description("${buildFormDescription(flagName, field)}").`); - lines.push(` Placeholder(${buildFormPlaceholder(cfgExpr, field)}).`); - lines.push(` Value(&${varName}),`); + lines.push( + ...goChain(`forms.NewInput(&${varName})`, [ + `Title("${escapeGoString(desc)}")`, + `Description("${buildFormDescription(flagName, field)}")`, + `Placeholder(${buildFormPlaceholder(cfgExpr, field)})`, + ]), + ); } return lines.join("\n "); diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 491837116..a18fdd29a 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -60,13 +60,13 @@ trackedFiles: go.mod: last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:f038ee2d6544c3a19715539ded7e3ff9d6f35af2 + last_write_checksum: sha1:ea2c92b9f1f890b5f46c22e9ee0b176ef6a043a1 internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:d5ccc0970afac880e050719fe49c883432062904 internal/cli/configure.go: - last_write_checksum: sha1:8585233e3a49552c702ef1abde8d862e61efac6b + last_write_checksum: sha1:c0eb651dc996a00cf7522256cb01446dc82adc18 internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -250,9 +250,11 @@ trackedFiles: internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/forms/forms.go: - last_write_checksum: sha1:6c871926327419a05adb8f1064ff30c030cda018 + last_write_checksum: sha1:3b4568ad1cfd1be9130b52f6c259beda572153ec + internal/forms/forms_test.go: + last_write_checksum: sha1:b9956f9fb7944412760a85cf2f9018f58b1cbfac internal/interactive/interactive.go: - last_write_checksum: sha1:e5574ecb0c290ecb3dae6e94edcf7190b5335070 + last_write_checksum: sha1:d2bedc457439e63a680674e84aedb2aea85a68ad internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index ade568dd8..4269a1abd 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -185,21 +185,19 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { accessible := formMode == interactive.FormAccessible var selectedScheme string - schemeSelect := forms.NewSelect(). + schemeSelect := forms.NewSelect(&selectedScheme, + forms.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), + forms.NewOption("HTTP Bearer + API Key", "option2"), + forms.NewOption("OAuth2 Authorization", "option3"), + forms.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), + forms.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), + forms.NewOption("OAuth2 Client Credentials", "option6"), + forms.NewOption("API Key", "my-api-key"), + ). Title("Authentication Method"). - Description("Choose which credentials to configure"). - Options( - forms.NewOption("HTTP Basic (Username + Password)", "user-pass-auth"), - forms.NewOption("HTTP Bearer + API Key", "option2"), - forms.NewOption("OAuth2 Authorization", "option3"), - forms.NewOption("Custom (Custom authentication credential + Custom authentication credential)", "option4"), - forms.NewOption("OAuth2 Password flow, a flow that is too\nlong to describe in a single line.", "option5"), - forms.NewOption("OAuth2 Client Credentials", "option6"), - forms.NewOption("API Key", "my-api-key"), - ). - Value(&selectedScheme) - - if err := forms.NewForm(forms.NewGroup(schemeSelect)).WithAccessible(accessible).Run(); err != nil { + Description("Choose which credentials to configure") + + if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -208,21 +206,19 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authUsername string var authPassword string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authUsername). Title("HTTP Basic username"). Description("--username"). - Placeholder(cfg.Security.Username). - Value(&authUsername), - forms.NewInput(). + Placeholder(cfg.Security.Username), + forms.NewInput(&authPassword). Title("HTTP Basic password"). Description("--password"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). - Value(&authPassword), + Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -242,22 +238,20 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authBearerAuth string var authMyApiKey string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authBearerAuth). Title("HTTP Bearer"). Description("--bearer-auth"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). - Value(&authBearerAuth), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))), + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -278,16 +272,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option3": var authOauth2 string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authOauth2). Title("OAuth2 Authorization"). Description("--oauth2"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). - Value(&authOauth2), + Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -303,22 +296,20 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authAppId string var authSecret string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authAppId). Title("Custom authentication credential"). Description("--app-id"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). - Value(&authAppId), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))), + forms.NewInput(&authSecret). Title("Custom authentication credential"). Description("--secret"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). - Value(&authSecret), + Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -339,16 +330,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "option5": var authMobileAuth string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authMobileAuth). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). - Value(&authMobileAuth), + Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -365,27 +355,24 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { var authClientSecret string var authTokenURL string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authClientID). Title("Client Credentials flow. client identifier"). Description("--client-id"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). - Value(&authClientID), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))), + forms.NewInput(&authClientSecret). Title("Client Credentials flow. client secret"). Description("--client-secret"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). - Value(&authClientSecret), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))), + forms.NewInput(&authTokenURL). Title("Client Credentials flow. token URL"). Description("--token-url"). - Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). - Value(&authTokenURL), + Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -410,16 +397,15 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { case "my-api-key": var authMyApiKey string - fields := []forms.Field{ - forms.NewInput(). + fields := []*forms.Field{ + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.NewForm(forms.NewGroup(fields...)).WithAccessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index e044548a0..218f9b65d 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -181,121 +181,104 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { var cfgGlobalLoneQueryParam string accessible := formMode == interactive.FormAccessible - var groups []*forms.Group - securityFields := []forms.Field{ - forms.NewInput(). + var pages []*forms.Page + securityFields := []*forms.Field{ + forms.NewInput(&authUsername). Title("HTTP Basic username"). Description("--username"). - Placeholder(cfg.Security.Username). - Value(&authUsername), - forms.NewInput(). + Placeholder(cfg.Security.Username), + forms.NewInput(&authPassword). Title("HTTP Basic password"). Description("--password"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))). - Value(&authPassword), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), + forms.NewInput(&authBearerAuth). Title("HTTP Bearer"). Description("--bearer-auth"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))). - Value(&authBearerAuth), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("bearer-auth", cfg.Security.BearerAuth))), + forms.NewInput(&authMyApiKey). Title("API Key"). Description("--my-api-key"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))). - Value(&authMyApiKey), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), + forms.NewInput(&authOauth2). Title("OAuth2 Authorization"). Description("--oauth2"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))). - Value(&authOauth2), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), + forms.NewInput(&authAppId). Title("Custom authentication credential"). Description("--app-id"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))). - Value(&authAppId), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("app-id", cfg.Security.AppId))), + forms.NewInput(&authSecret). Title("Custom authentication credential"). Description("--secret"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))). - Value(&authSecret), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), + forms.NewInput(&authMobileAuth). Title("OAuth2 Password flow, a flow that is too\nlong to describe in a single line."). Description("--mobile-auth"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))). - Value(&authMobileAuth), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), + forms.NewInput(&authClientID). Title("Client Credentials flow. client identifier"). Description("--client-id"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))). - Value(&authClientID), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("client-id", cfg.Security.ClientID))), + forms.NewInput(&authClientSecret). Title("Client Credentials flow. client secret"). Description("--client-secret"). Password(). - Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))). - Value(&authClientSecret), - forms.NewInput(). + Placeholder(maskSecret(config.GetStoredSecret("client-secret", cfg.Security.ClientSecret))), + forms.NewInput(&authTokenURL). Title("Client Credentials flow. token URL"). Description("--token-url"). - Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")). - Value(&authTokenURL), + Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - groups = append(groups, forms.NewGroup(securityFields...).Title("Authentication")) - globalFields := []forms.Field{ - forms.NewInput(). + pages = append(pages, forms.NewPage("Authentication", securityFields...)) + globalFields := []*forms.Field{ + forms.NewInput(&cfgGlobalQueryParam1). Title("A long winded, multi-line description"). Description("--query-param1"). - Placeholder(cfg.Globals.QueryParam1). - Value(&cfgGlobalQueryParam1), - forms.NewInput(). + Placeholder(cfg.Globals.QueryParam1), + forms.NewInput(&cfgGlobalDeprecatedQueryParam1). Title("A deprecated description"). Description("--deprecated-query-param1"). - Placeholder(cfg.Globals.DeprecatedQueryParam1). - Value(&cfgGlobalDeprecatedQueryParam1), - forms.NewInput(). + Placeholder(cfg.Globals.DeprecatedQueryParam1), + forms.NewInput(&cfgGlobalDeprecatedQueryParam2). Title("Global deprecated-query-param2 parameter"). Description("--deprecated-query-param2"). - Placeholder(cfg.Globals.DeprecatedQueryParam2). - Value(&cfgGlobalDeprecatedQueryParam2), - forms.NewInput(). + Placeholder(cfg.Globals.DeprecatedQueryParam2), + forms.NewInput(&cfgGlobalLoneQueryParam). Title("Global lone-query-param parameter"). Description("--lone-query-param"). - Placeholder(cfg.Globals.LoneQueryParam). - Value(&cfgGlobalLoneQueryParam), + Placeholder(cfg.Globals.LoneQueryParam), } - groups = append(groups, forms.NewGroup(globalFields...).Title("Global Parameters")) + pages = append(pages, forms.NewPage("Global Parameters", globalFields...)) - // Preference fields use forms.Select which loops forever on EOF in - // accessible mode (non-TTY). Only show them when truly interactive. + // Preferences are only offered in the interactive form; line prompts + // cover credentials and global parameters. var cfgOutputFormat string if !accessible { - preferenceFields := []forms.Field{ - forms.NewSelect(). + preferenceFields := []*forms.Field{ + forms.NewSelect(&cfgOutputFormat, + forms.NewOption("Keep current", ""), + forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), + forms.NewOption("pretty", "pretty"), + forms.NewOption("json", "json"), + forms.NewOption("yaml", "yaml"), + forms.NewOption("table", "table"), + forms.NewOption("toon", "toon"), + ). Title("Default output format"). - Description("Choose the default response rendering format for this CLI"). - Options( - forms.NewOption("Keep current", ""), - forms.NewOption("Clear (use built-in default: pretty)", "__CLEAR__"), - forms.NewOption("pretty", "pretty"), - forms.NewOption("json", "json"), - forms.NewOption("yaml", "yaml"), - forms.NewOption("table", "table"), - forms.NewOption("toon", "toon"), - ). - Value(&cfgOutputFormat), + Description("Choose the default response rendering format for this CLI"), } - groups = append(groups, forms.NewGroup(preferenceFields...).Title("Preferences")) + pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := forms.NewForm(groups...).WithAccessible(accessible) + form := forms.New(pages...).Accessible(accessible) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go index 9c7a09282..3727d827b 100644 --- a/zSDKs/sdk-cli/internal/forms/forms.go +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -1,38 +1,12 @@ // Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. -// MIT License -// -// Copyright (c) 2023–2026 Charm -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -// Package forms renders the terminal forms used by guided prompts: text -// inputs, multi-line text, selects, and confirmations, with an accessible -// line-prompt mode for screen readers and non-TTY input. -// -// It is adapted from github.com/charmbracelet/huh v2.0.3 (MIT license above), -// reduced to the fields and form options this CLI uses. +// Package forms asks the questions behind the CLI's guided setup: pages of +// text, secret, JSON, choice and yes/no fields. Terminals that can redraw get +// an interactive form; everything else gets plain line prompts. package forms import ( "bufio" - "cmp" "errors" "fmt" "io" @@ -40,1239 +14,584 @@ import ( "strconv" "strings" - "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "golang.org/x/term" ) -// ErrUserAborted is returned by Run when the user cancels the form. +// ErrUserAborted is returned by Run when the user cancels with ctrl+c. var ErrUserAborted = errors.New("user aborted") -// Field is one input control on a form. -type Field interface { - Init() tea.Cmd - Update(msg tea.Msg) (Field, tea.Cmd) - View() string - Focus() tea.Cmd - Blur() tea.Cmd - Error() error - RunAccessible(w io.Writer, r io.Reader) error - - setWidth(width int) - setHeight(height int) - setPosition(first, last bool) -} - -type nextFieldMsg struct{} - -type prevFieldMsg struct{} - -type nextGroupMsg struct{} - -type prevGroupMsg struct{} - -func nextField() tea.Msg { return nextFieldMsg{} } - -func prevField() tea.Msg { return prevFieldMsg{} } - -func nextGroup() tea.Msg { return nextGroupMsg{} } +var ( + accent = lipgloss.Color("#38BDF8") + dimmed = lipgloss.Color("#64748B") + subtle = lipgloss.Color("#475569") + errColor = lipgloss.Color("#F87171") +) -func prevGroup() tea.Msg { return prevGroupMsg{} } +type fieldKind int -// navKeys moves between fields. The form's first field has no previous -// field, and only enter submits from its last field. -type navKeys struct { - prev, next key.Binding -} +const ( + kindInput fieldKind = iota + kindText + kindSelect + kindConfirm +) -func newNavKeys(next ...string) navKeys { - return navKeys{ - prev: key.NewBinding(key.WithKeys("shift+tab")), - next: key.NewBinding(key.WithKeys(next...)), - } +// Option is a choice in a select field: Label is shown, Value is stored. +type Option struct { + Label string + Value string } -func (n *navKeys) setPosition(first, last bool) { - n.prev.SetEnabled(!first) - if last { - n.next.SetKeys("enter") - } +// NewOption returns a select option. +func NewOption(label, value string) Option { + return Option{Label: label, Value: value} } -// Form shows one group of fields at a time and completes after the last group. -type Form struct { - groups []*Group - index int - - accessible bool - width int - quit key.Binding +// Field is one question. Its answer is written to the variable passed to the +// constructor as the user edits it. +type Field struct { + kind fieldKind + title string + description string + validate func(string) error + err error - done bool - quitting bool - aborted bool + text *string // input, text and select answers + yes *bool // confirm answer + secret bool - submitCmd tea.Cmd - cancelCmd tea.Cmd -} + options []Option + cursor int -// NewForm returns a form sized to the terminal width. -func NewForm(groups ...*Group) *Form { - f := &Form{ - groups: groups, - quit: key.NewBinding(key.WithKeys("ctrl+c")), - // Dumb terminals cannot redraw, so they get line prompts. - accessible: os.Getenv("TERM") == "dumb", - } - for g, group := range groups { - for i, field := range group.fields { - field.setPosition(g == 0 && i == 0, g == len(groups)-1 && i == len(group.fields)-1) - } - } - f.width = terminalWidth() - for _, group := range groups { - group.setWidth(f.width) - } - return f + input textinput.Model + area textarea.Model } -func terminalWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - return 80 - } - return width +// NewInput asks for a single line of text, starting from *value. +func NewInput(value *string) *Field { + in := textinput.New() + in.SetValue(*value) + return &Field{kind: kindInput, text: value, input: in} } -// WithAccessible replaces the interactive renderer with line prompts that -// read stdin, for screen readers and non-terminal input. -func (f *Form) WithAccessible(accessible bool) *Form { - f.accessible = accessible - return f +// NewText asks for multi-line text such as JSON, starting from *value. +// alt+enter or ctrl+j inserts a newline; enter moves on. +func NewText(value *string) *Field { + area := textarea.New() + area.ShowLineNumbers = false + area.Prompt = "" + area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetValue(*value) + return &Field{kind: kindText, text: value, area: area} } -// Run runs the form until it is submitted or cancelled. -func (f *Form) Run() error { - f.submitCmd = tea.Quit - f.cancelCmd = tea.Interrupt - if len(f.groups) == 0 { - return nil - } - if f.accessible { - for _, group := range f.groups { - for _, field := range group.fields { - field.Init() - field.Focus() - _ = field.RunAccessible(os.Stdout, os.Stdin) - fmt.Fprintln(os.Stdout) - } - } - return nil - } - m, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run() - if m.(*Form).aborted || errors.Is(err, tea.ErrInterrupted) { - return ErrUserAborted - } - if err != nil { - return fmt.Errorf("form: %w", err) - } - return nil -} - -// Init implements [tea.Model]. -func (f *Form) Init() tea.Cmd { - cmds := make([]tea.Cmd, 0, len(f.groups)+1) - for i, group := range f.groups { - group.active = i == 0 - cmds = append(cmds, group.Init()) - } - cmds = append(cmds, tea.RequestWindowSize) - return tea.Sequence(cmds...) -} - -// Update implements [tea.Model]. -func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if f.done { - return f, nil - } - group := f.groups[f.index] - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - // Size every group to the tallest one so pages don't jump. - needed := 0 - for _, g := range f.groups { - needed = max(needed, g.rawHeight()) - } - for _, g := range f.groups { - g.setHeight(min(needed, msg.Height)) - } - case tea.KeyPressMsg: - if key.Matches(msg, f.quit) { - f.aborted = true - f.quitting = true - f.done = true - return f, f.cancelCmd - } - case nextGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - if f.index == len(f.groups)-1 { - f.quitting = true - f.done = true - return f, f.submitCmd - } - f.index++ - f.groups[f.index].active = true - return f, f.groups[f.index].Init() - case prevGroupMsg: - if len(group.Errors()) > 0 { - return f, nil - } - if f.index > 0 { - f.index-- +// NewSelect asks for one of options, starting at the option matching *value. +func NewSelect(value *string, options ...Option) *Field { + f := &Field{kind: kindSelect, text: value, options: options} + for i, o := range options { + if o.Value == *value { + f.cursor = i + break } - f.groups[f.index].active = true - return f, f.groups[f.index].Init() } - - return f, group.Update(msg) -} - -// View implements [tea.Model]. -func (f *Form) View() tea.View { - var v tea.View - v.ReportFocus = true - v.SetContent(f.render()) - return v -} - -func (f *Form) render() string { - if f.quitting { - return "" + if len(options) > 0 { + *value = options[f.cursor].Value } - return lipgloss.NewStyle().Render(f.groups[f.index].View()) + return f } -// Group is a page of fields shown together. -type Group struct { - fields []Field - index int - title string - - viewport viewport.Model - width int - height int - active bool +// NewConfirm asks a yes/no question, starting from *value. +func NewConfirm(value *bool) *Field { + return &Field{kind: kindConfirm, yes: value} } -// NewGroup returns a group of the given fields. -func NewGroup(fields ...Field) *Group { - g := &Group{fields: fields, width: 80} - g.height = g.rawHeight() - g.viewport = viewport.New(viewport.WithWidth(g.width), viewport.WithHeight(g.height)) - return g -} +// Title sets the question. +func (f *Field) Title(title string) *Field { f.title = title; return f } -// Title sets the heading shown above the group's fields. -func (g *Group) Title(title string) *Group { - g.title = title - return g -} +// Description sets the hint shown under the question. +func (f *Field) Description(description string) *Field { f.description = description; return f } -func (g *Group) setWidth(width int) { - g.width = width - g.viewport.SetWidth(width) - for _, field := range g.fields { - field.setWidth(width) - } +// Placeholder sets the text shown while an input is empty. +func (f *Field) Placeholder(placeholder string) *Field { + f.input.Placeholder = placeholder + return f } -func (g *Group) setHeight(height int) { - g.height = height - h := height - g.titleFooterHeight() - g.viewport.SetHeight(h) - for _, field := range g.fields { - // A field height must not exceed the form height. - if h < lipgloss.Height(field.View()) { - field.setHeight(h) - } - } +// Password hides what is typed into an input. +func (f *Field) Password() *Field { + f.secret = true + f.input.EchoMode = textinput.EchoPassword + return f } -// Errors returns the validation errors of the group's fields. -func (g *Group) Errors() []error { - var errs []error - for _, field := range g.fields { - if err := field.Error(); err != nil { - errs = append(errs, err) - } - } - return errs +// Validate rejects answers before the form moves past the field. +func (f *Field) Validate(validate func(string) error) *Field { + f.validate = validate + return f } -// Init focuses the current field when the group is active. -func (g *Group) Init() tea.Cmd { - var cmd tea.Cmd - if g.active { - cmd = g.fields[g.index].Focus() +func (f *Field) check() error { + if f.validate == nil || f.text == nil { + return nil } - g.buildView() - return cmd + return f.validate(*f.text) } -func (g *Group) nextField() []tea.Cmd { - blur := g.fields[g.index].Blur() - if g.index == len(g.fields)-1 { - return []tea.Cmd{blur, nextGroup} +func (f *Field) focus() tea.Cmd { + switch f.kind { + case kindInput: + return f.input.Focus() + case kindText: + return f.area.Focus() } - g.index++ - return []tea.Cmd{blur, g.fields[g.index].Focus()} + return nil } -func (g *Group) prevField() []tea.Cmd { - blur := g.fields[g.index].Blur() - if g.index == 0 { - return []tea.Cmd{blur, prevGroup} - } - g.index-- - return []tea.Cmd{blur, g.fields[g.index].Focus()} +func (f *Field) blur() { + switch f.kind { + case kindInput: + f.input.Blur() + case kindText: + f.area.Blur() + } +} + +func (f *Field) setWidth(width int) { + inner := max(width-4, 10) + switch f.kind { + case kindInput: + f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) + case kindText: + f.area.SetWidth(inner) + } +} + +// update applies a message to the focused field. advance reports that the +// key also answered the question (y or n on a confirm). +func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { + key, isKey := msg.(tea.KeyPressMsg) + if isKey { + f.err = nil + } + switch f.kind { + case kindInput: + f.input, cmd = f.input.Update(msg) + *f.text = f.input.Value() + case kindText: + f.area, cmd = f.area.Update(msg) + *f.text = f.area.Value() + case kindSelect: + if !isKey || len(f.options) == 0 { + return nil, false + } + n := len(f.options) + switch key.String() { + case "up", "k", "ctrl+p": + f.cursor = (f.cursor - 1 + n) % n + case "down", "j", "ctrl+n": + f.cursor = (f.cursor + 1) % n + case "home", "g": + f.cursor = 0 + case "end", "G": + f.cursor = n - 1 + } + *f.text = f.options[f.cursor].Value + case kindConfirm: + if !isKey { + return nil, false + } + switch key.String() { + case "left", "right", "h", "l": + *f.yes = !*f.yes + case "y", "Y": + *f.yes = true + return nil, true + case "n", "N": + *f.yes = false + return nil, true + } + } + return cmd, false +} + +// Page is a set of fields shown together under an optional heading. +type Page struct { + title string + fields []*Field } -// Update routes key presses and pastes to the focused field and every other -// message to all fields. -func (g *Group) Update(msg tea.Msg) tea.Cmd { - var cmds []tea.Cmd - for i, field := range g.fields { - switch msg.(type) { - case tea.KeyPressMsg, tea.PasteMsg: - default: - m, cmd := field.Update(msg) - g.fields[i] = m - cmds = append(cmds, cmd) - } - if i == g.index { - m, cmd := field.Update(msg) - g.fields[i] = m - cmds = append(cmds, cmd) - } - } - - switch msg.(type) { - case nextFieldMsg: - cmds = append(cmds, g.nextField()...) - case prevFieldMsg: - cmds = append(cmds, g.prevField()...) - } - - g.buildView() - return tea.Batch(cmds...) +// NewPage groups fields onto one screen. +func NewPage(title string, fields ...*Field) *Page { + return &Page{title: title, fields: fields} } -func (g *Group) content() (int, string) { - var fields strings.Builder - offset := 0 - for i, field := range g.fields { - fields.WriteString(field.View()) - if i == g.index { - offset = lipgloss.Height(fields.String()) - lipgloss.Height(field.View()) - } - if i < len(g.fields)-1 { - fields.WriteString("\n\n") - } - } - return offset, fields.String() +// Form steps through pages of fields. enter and tab move forward, shift+tab +// moves back, and enter on the last field submits. +type Form struct { + pages []*Page + page int + field int + accessible bool + width int + height int + finished bool + aborted bool } -func (g *Group) buildView() { - offset, content := g.content() - g.viewport.SetContent(content) - g.viewport.SetYOffset(offset) +// New returns a form over pages. Dumb terminals default to line prompts. +func New(pages ...*Page) *Form { + f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb"} + f.resize(terminalWidth(), 0) + return f } -func (g *Group) header() string { - if g.title == "" { - return "" - } - return lipgloss.NewStyle().Render(wrap(g.title, g.width)) +// Accessible switches between the interactive form and line prompts. +func (f *Form) Accessible(on bool) *Form { + f.accessible = on + return f } -func (g *Group) footer() string { - var parts []string - for _, err := range g.Errors() { - parts = append(parts, wrap(focusedStyles.ErrorMessage.Render(err.Error()), g.width)) +func terminalWidth() int { + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return width } - return lipgloss.NewStyle().Render(strings.Join(parts, "\n")) + return 80 } -func (g *Group) titleFooterHeight() int { - h := 0 - if s := g.header(); s != "" { - h += lipgloss.Height(s) +// Run asks every question and returns once the form is submitted. Answers are +// written to the variables bound to each field. +func (f *Form) Run() error { + if f.empty() { + return nil } - if s := g.footer(); s != "" { - h += lipgloss.Height(s) + if f.accessible { + f.prompt(os.Stdout, os.Stdin) + return nil } - return h -} - -// rawHeight is the group's full height without a viewport. -func (g *Group) rawHeight() int { - _, content := g.content() - return lipgloss.Height(content) + g.titleFooterHeight() -} - -// View renders the group. -func (g *Group) View() string { - var parts []string - if s := g.header(); s != "" { - parts = append(parts, s) + if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return ErrUserAborted + } + return fmt.Errorf("form: %w", err) } - parts = append(parts, g.viewport.View()) - if s := g.footer(); s != "" { - parts = append(parts, "", s) + if f.aborted { + return ErrUserAborted } - // A trailing space in the bottom-right cell scrolls some terminals. - last := len(parts) - 1 - parts[last] = strings.TrimSuffix(parts[last], " ") - return strings.Join(parts, "\n") -} - -func wrap(s string, limit int) string { - return lipgloss.Wrap(s, limit, ",.-; ") -} - -func noValidation(string) error { return nil } - -// Input is a single-line text field. -type Input struct { - value *string - title string - description string - textinput textinput.Model - validate func(string) error - err error - focused bool - width int - height int - nav navKeys -} - -// NewInput returns a single-line text field. -func NewInput() *Input { - return &Input{value: new(string), textinput: textinput.New(), validate: noValidation, nav: newNavKeys("enter", "tab")} -} - -// Value binds the field to value, which also supplies the initial text. -func (i *Input) Value(value *string) *Input { - i.value = value - i.textinput.SetValue(*value) - return i -} - -// Title sets the field title. -func (i *Input) Title(title string) *Input { i.title = title; return i } - -// Description sets the text shown under the title. -func (i *Input) Description(description string) *Input { i.description = description; return i } - -// Placeholder sets the text shown while the field is empty. -func (i *Input) Placeholder(placeholder string) *Input { - i.textinput.Placeholder = placeholder - return i -} - -// Password masks the typed characters. -func (i *Input) Password() *Input { - i.textinput.EchoMode = textinput.EchoPassword - return i -} - -// Validate sets a check that runs on submit and when the field loses focus. -func (i *Input) Validate(validate func(string) error) *Input { - i.validate = validate - return i -} - -func (i *Input) Error() error { return i.err } - -func (i *Input) Focus() tea.Cmd { - i.focused = true - return i.textinput.Focus() -} - -func (i *Input) Blur() tea.Cmd { - i.focused = false - *i.value = i.textinput.Value() - i.textinput.Blur() - i.err = i.validate(*i.value) - return nil -} - -func (i *Input) Init() tea.Cmd { - i.textinput.Blur() return nil } -func (i *Input) Update(msg tea.Msg) (Field, tea.Cmd) { - var cmds []tea.Cmd - if msg, ok := msg.(tea.KeyPressMsg); ok { - i.err = nil - switch { - case key.Matches(msg, i.nav.prev): - cmds = append(cmds, prevField) - case key.Matches(msg, i.nav.next): - if i.err = i.validate(i.textinput.Value()); i.err != nil { - return i, nil - } - cmds = append(cmds, nextField) +func (f *Form) empty() bool { + for _, p := range f.pages { + if len(p.fields) > 0 { + return false } } - var cmd tea.Cmd - i.textinput, cmd = i.textinput.Update(msg) - cmds = append(cmds, cmd) - *i.value = i.textinput.Value() - return i, tea.Batch(cmds...) + return true } -func (i *Input) View() string { - styles := stylesFor(i.focused) - maxWidth := i.width - styles.Base.GetHorizontalFrameSize() - - st := i.textinput.Styles() - st.Cursor.Color = styles.TextInput.Cursor.GetForeground() - st.Focused.Prompt = styles.TextInput.Prompt - st.Focused.Text = styles.TextInput.Text - st.Focused.Placeholder = styles.TextInput.Placeholder - i.textinput.SetStyles(st) - - var sb strings.Builder - if i.title != "" { - sb.WriteString(styles.Title.Render(wrap(i.title, maxWidth)) + "\n") - } - if i.description != "" { - sb.WriteString(styles.Description.Render(wrap(i.description, maxWidth)) + "\n") - } - sb.WriteString(i.textinput.View()) - return styles.Base.Width(i.width).Height(i.height).Render(sb.String()) -} - -func (i *Input) RunAccessible(w io.Writer, r io.Reader) error { - title := stylesFor(i.focused).Title.PaddingRight(1) - if i.textinput.EchoMode == textinput.EchoNormal { - *i.value = promptString(w, r, title.Render(cmp.Or(i.title, "Input:")), *i.value, i.validate) - return nil - } - fd, ok := r.(interface{ Fd() uintptr }) - if !ok { - return errors.New("password asking needs a tty") - } - value, err := promptPassword(w, fd.Fd(), title.Render(cmp.Or(i.title, "Password:")), i.validate) - if err != nil { - return err +func (f *Form) resize(width, height int) { + f.width, f.height = width, height + for _, p := range f.pages { + for _, field := range p.fields { + field.setWidth(width) + } } - *i.value = value - return nil -} - -func (i *Input) setWidth(width int) { - i.width = width - frame := stylesFor(i.focused).Base.GetHorizontalFrameSize() - prompt := lipgloss.Width(i.textinput.Styles().Focused.Prompt.Render(i.textinput.Prompt)) - i.textinput.SetWidth(width - frame - prompt - 1) -} - -func (i *Input) setHeight(height int) { i.height = height } - -func (i *Input) setPosition(first, last bool) { i.nav.setPosition(first, last) } - -// Text is a multi-line text field; alt+enter or ctrl+j inserts a newline. -type Text struct { - value *string - title string - description string - textarea textarea.Model - validate func(string) error - err error - focused bool - width int - nav navKeys -} - -// NewText returns a multi-line text field. -func NewText() *Text { - ta := textarea.New() - ta.ShowLineNumbers = false - ta.Prompt = "" - st := ta.Styles() - st.Focused.CursorLine = lipgloss.NewStyle() - ta.SetStyles(st) - ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") - return &Text{value: new(string), textarea: ta, validate: noValidation, nav: newNavKeys("tab", "enter")} -} - -// Value binds the field to value, which also supplies the initial text. -func (t *Text) Value(value *string) *Text { - t.value = value - t.textarea.SetValue(*value) - return t } -// Title sets the field title. -func (t *Text) Title(title string) *Text { t.title = title; return t } - -// Description sets the text shown under the title. -func (t *Text) Description(description string) *Text { t.description = description; return t } - -// Validate sets a check that runs on submit and when the field loses focus. -func (t *Text) Validate(validate func(string) error) *Text { - t.validate = validate - return t -} - -func (t *Text) Error() error { return t.err } - -func (t *Text) Focus() tea.Cmd { - t.focused = true - return t.textarea.Focus() -} - -func (t *Text) Blur() tea.Cmd { - t.focused = false - *t.value = t.textarea.Value() - t.textarea.Blur() - t.err = t.validate(*t.value) - return nil +func (f *Form) current() *Field { + return f.pages[f.page].fields[f.field] } -func (t *Text) Init() tea.Cmd { - t.textarea.Blur() - return nil +func (f *Form) onLastField() bool { + return f.page == len(f.pages)-1 && f.field == len(f.pages[f.page].fields)-1 } -func (t *Text) Update(msg tea.Msg) (Field, tea.Cmd) { - var cmds []tea.Cmd - if msg, ok := msg.(tea.KeyPressMsg); ok { - t.err = nil +// step moves the focus by one field across page boundaries, skipping empty +// pages. It reports false when there is no field in that direction. +func (f *Form) step(delta int) bool { + page, field := f.page, f.field+delta + for { switch { - case key.Matches(msg, t.nav.next): - if t.err = t.validate(t.textarea.Value()); t.err != nil { - return t, nil + case field < 0: + page-- + if page < 0 { + return false } - cmds = append(cmds, nextField) - case key.Matches(msg, t.nav.prev): - if t.err = t.validate(t.textarea.Value()); t.err != nil { - return t, nil + field = len(f.pages[page].fields) - 1 + continue + case field >= len(f.pages[page].fields): + page++ + if page >= len(f.pages) { + return false } - cmds = append(cmds, prevField) - } - } - var cmd tea.Cmd - t.textarea, cmd = t.textarea.Update(msg) - cmds = append(cmds, cmd) - *t.value = t.textarea.Value() - return t, tea.Batch(cmds...) -} - -func (t *Text) View() string { - styles := stylesFor(t.focused) - st := t.textarea.Styles() - state := &st.Blurred - if t.focused { - state = &st.Focused - } - state.Placeholder = styles.TextInput.Placeholder - state.Text = styles.TextInput.Text - state.Prompt = styles.TextInput.Prompt - state.CursorLine = styles.TextInput.Text - st.Cursor.Color = styles.TextInput.Cursor.GetBackground() - t.textarea.SetStyles(st) - - maxWidth := t.width - styles.Base.GetHorizontalFrameSize() - var parts []string - if t.title != "" { - header := styles.Title.Render(wrap(t.title, maxWidth)) - if t.err != nil { - header += styles.ErrorIndicator.String() + field = 0 + continue } - parts = append(parts, header) + f.page, f.field = page, field + return true } - if t.description != "" { - parts = append(parts, styles.Description.Render(wrap(t.description, maxWidth))) - } - parts = append(parts, t.textarea.View()) - return styles.Base.Render(strings.Join(parts, "\n")) -} - -func (t *Text) RunAccessible(w io.Writer, r io.Reader) error { - prompt := stylesFor(t.focused).Title.PaddingRight(1).Render(cmp.Or(t.title, "Input:")) - *t.value = promptString(w, r, prompt, *t.value, t.validate) - return nil -} - -func (t *Text) setWidth(width int) { - t.width = width - t.textarea.SetWidth(width - stylesFor(t.focused).Base.GetHorizontalFrameSize()) } -func (t *Text) setHeight(height int) { - adjust := 0 - if t.title != "" { - adjust++ - } - if t.description != "" { - adjust++ +// Init implements [tea.Model]. +func (f *Form) Init() tea.Cmd { + for f.field >= len(f.pages[f.page].fields) { + f.page++ } - t.textarea.SetHeight(height - stylesFor(t.focused).Base.GetVerticalFrameSize() - adjust) -} - -func (t *Text) setPosition(first, last bool) { t.nav.setPosition(first, last) } - -// Option is a choice in a [Select]: Key is shown, Value is stored. -type Option struct { - Key string - Value string -} - -// NewOption returns a select option. -func NewOption(key, value string) Option { - return Option{Key: key, Value: value} + return tea.Batch(f.current().focus(), tea.RequestWindowSize) } -var ( - selectUp = key.NewBinding(key.WithKeys("up", "k", "ctrl+k", "ctrl+p")) - selectDown = key.NewBinding(key.WithKeys("down", "j", "ctrl+j", "ctrl+n")) - selectTop = key.NewBinding(key.WithKeys("home", "g")) - selectBottom = key.NewBinding(key.WithKeys("end", "G")) - selectHalfUp = key.NewBinding(key.WithKeys("ctrl+u")) - selectHalfDown = key.NewBinding(key.WithKeys("ctrl+d")) -) - -// Select picks one option from a list. -type Select struct { - value *string - title string - description string - options []Option - selected int - focused bool - width int - height int - viewport viewport.Model - nav navKeys -} - -// NewSelect returns a single-choice list. -func NewSelect() *Select { - return &Select{value: new(string), nav: newNavKeys("enter", "tab")} -} - -// Value binds the field to value and moves the cursor to the matching option. -func (s *Select) Value(value *string) *Select { - s.value = value - for i, o := range s.options { - if o.Value == *value { - s.selected = i - break +// Update implements [tea.Model]. +func (f *Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + f.resize(msg.Width, msg.Height) + return f, nil + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + f.aborted = true + return f, tea.Quit + case "shift+tab": + return f, f.move(-1) + case "tab": + if f.onLastField() { + return f, nil + } + return f, f.forward() + case "enter": + return f, f.forward() } } - s.updateValue() - return s -} - -// Title sets the field title. -func (s *Select) Title(title string) *Select { s.title = title; return s } - -// Description sets the text shown under the title. -func (s *Select) Description(description string) *Select { s.description = description; return s } - -// Options sets the choices. -func (s *Select) Options(options ...Option) *Select { - if len(options) == 0 { - return s + cmd, advance := f.current().update(msg) + if advance { + return f, tea.Batch(cmd, f.forward()) } - s.options = options - s.selectCurrent() - s.updateViewportSize() - s.updateValue() - return s + return f, cmd } -func (s *Select) selectCurrent() { - for i, o := range s.options { - if o.Value == *s.value { - s.selected = i - break - } - } - s.ensureCursorVisible() -} - -func (s *Select) Error() error { return nil } - -func (s *Select) Focus() tea.Cmd { - s.focused = true - return nil -} - -func (s *Select) Blur() tea.Cmd { - s.focused = false - return nil -} - -func (s *Select) Init() tea.Cmd { return nil } - -func (s *Select) Update(msg tea.Msg) (Field, tea.Cmd) { - s.updateViewportSize() - msgKey, ok := msg.(tea.KeyPressMsg) - if !ok { - return s, nil +// forward validates the focused field, then moves on or submits. +func (f *Form) forward() tea.Cmd { + cur := f.current() + if cur.err = cur.check(); cur.err != nil { + return nil } - last := len(s.options) - 1 - switch { - case key.Matches(msgKey, selectUp): - s.selected-- - if s.selected < 0 { - s.selected = last - s.viewport.GotoBottom() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msgKey, selectTop): - s.selected = 0 - s.viewport.GotoTop() - s.updateValue() - case key.Matches(msgKey, selectBottom): - s.selected = last - s.viewport.GotoBottom() - case key.Matches(msgKey, selectHalfUp): - s.selected = max(s.selected-s.viewport.Height()/2, 0) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msgKey, selectHalfDown): - s.selected = min(s.selected+s.viewport.Height()/2, last) - s.ensureCursorVisible() - s.updateValue() - case key.Matches(msgKey, selectDown): - s.selected++ - if s.selected > last { - s.selected = 0 - s.viewport.GotoTop() - } else { - s.ensureCursorVisible() - } - s.updateValue() - case key.Matches(msgKey, s.nav.prev): - if s.selected > last { - break - } - s.updateValue() - return s, prevField - case key.Matches(msgKey, s.nav.next): - if s.selected > last { - break - } - s.updateValue() - return s, nextField + if f.onLastField() { + f.finished = true + return tea.Quit } - s.ensureCursorVisible() - return s, nil + return f.move(1) } -func (s *Select) updateValue() { - if s.selected >= 0 && s.selected < len(s.options) { - *s.value = s.options[s.selected].Value +func (f *Form) move(delta int) tea.Cmd { + prev := f.current() + if !f.step(delta) { + return nil } + prev.blur() + return f.current().focus() } -func (s *Select) updateViewportSize() { - if s.height > 0 { - yoffset := 0 - if v := s.titleView(); v != "" { - yoffset += lipgloss.Height(v) - } - if v := s.descriptionView(); v != "" { - yoffset += lipgloss.Height(v) - } - s.viewport.SetHeight(max(1, s.height-yoffset)) - s.ensureCursorVisible() - } else { - s.viewport.SetHeight(lipgloss.Height(s.optionsView())) - } - if s.width > 0 { - s.viewport.SetWidth(s.width) - } else { - s.viewport.SetWidth(lipgloss.Width(s.optionsView())) +// View implements [tea.Model]. +func (f *Form) View() tea.View { + if f.finished || f.aborted { + return tea.NewView("") } + return tea.NewView(f.render()) } -func (s *Select) maxWidth() int { - return s.width - stylesFor(s.focused).Base.GetHorizontalFrameSize() -} - -func (s *Select) titleView() string { - return stylesFor(s.focused).Title.Render(wrap(s.title, s.maxWidth())) -} - -func (s *Select) descriptionView() string { - if s.description == "" { - return "" +func (f *Form) render() string { + page := f.pages[f.page] + var b strings.Builder + if page.title != "" { + b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(page.title) + "\n\n") } - return stylesFor(s.focused).Description.Render(wrap(s.description, s.maxWidth())) -} - -func (s *Select) optionsView() string { - var sb strings.Builder - for i, option := range s.options { - sb.WriteString(s.renderOption(option, i == s.selected)) - if i < len(s.options)-1 { - sb.WriteString("\n") + for i, field := range page.fields { + if i > 0 { + b.WriteString("\n\n") } + b.WriteString(f.renderField(field, i == f.field)) } - return sb.String() + return b.String() } -func (s *Select) renderOption(option Option, selected bool) string { - styles := stylesFor(s.focused) - cursor := styles.SelectSelector.String() - cursorWidth := lipgloss.Width(cursor) - label := wrap(option.Key, s.maxWidth()-cursorWidth) - if selected { - return lipgloss.JoinHorizontal(lipgloss.Left, cursor, styles.SelectedOption.Render(label)) +func (f *Form) renderField(field *Field, focused bool) string { + inner := max(f.width-4, 10) + bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) + if focused { + bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) + } + lines := []string{} + if field.title != "" { + lines = append(lines, title.Render(lipgloss.Wrap(field.title, inner, " "))) + } + if field.description != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(lipgloss.Wrap(field.description, inner, " "))) + } + switch field.kind { + case kindInput: + lines = append(lines, field.input.View()) + case kindText: + lines = append(lines, field.area.View()) + case kindSelect: + lines = append(lines, f.renderOptions(field, focused)) + case kindConfirm: + lines = append(lines, renderButtons(*field.yes, focused)) + } + if field.err != nil { + lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) + } + return lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). + PaddingLeft(1). + Render(strings.Join(lines, "\n")) +} + +// renderOptions shows a window of options around the cursor that fits the +// terminal height. +func (f *Form) renderOptions(field *Field, focused bool) string { + rows := len(field.options) + if f.height > 0 { + rows = min(rows, max(3, f.height-8)) + } + start := min(max(field.cursor-rows/2, 0), len(field.options)-rows) + picked := lipgloss.NewStyle().Foreground(dimmed) + if focused { + picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - return lipgloss.JoinHorizontal(lipgloss.Left, strings.Repeat(" ", cursorWidth), styles.UnselectedOption.Render(label)) -} - -// ensureCursorVisible scrolls the viewport the minimum amount to show the -// selected option. -func (s *Select) ensureCursorVisible() { - offset, height := 0, 0 - for i, option := range s.options { - h := lipgloss.Height(s.renderOption(option, i == s.selected)) - if i == s.selected { - height = h - break + var lines []string + for i := start; i < start+rows; i++ { + if i == field.cursor { + lines = append(lines, picked.Render("> "+field.options[i].Label)) + } else { + lines = append(lines, " "+field.options[i].Label) } - offset += h - } - if height <= 0 { - return } - if y := s.viewport.YOffset(); offset < y { - s.viewport.ScrollUp(y - offset) - } else if end := y + s.viewport.Height(); offset+height > end { - s.viewport.ScrollDown(offset + height - end) - } -} - -func (s *Select) View() string { - s.viewport.SetContent(s.optionsView()) - var parts []string - if s.title != "" { - parts = append(parts, s.titleView()) + if rows < len(field.options) { + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(field.options)))) } - if s.description != "" { - parts = append(parts, s.descriptionView()) - } - parts = append(parts, s.viewport.View()) - return stylesFor(s.focused).Base.Width(s.width).Height(s.height).Render(strings.Join(parts, "\n")) -} - -func (s *Select) RunAccessible(w io.Writer, r io.Reader) error { - fmt.Fprintln(w, stylesFor(s.focused).Title.PaddingRight(1).Render(cmp.Or(s.title, "Select:"))) - for i, option := range s.options { - fmt.Fprintf(w, "%d. %s\n", i+1, option.Key) - } - s.selectCurrent() - prompt := fmt.Sprintf("Enter a number between %d and %d: ", 1, len(s.options)) - if len(s.options) == 1 { - prompt = "There is only one option available; enter the number 1:" - } - choice := promptInt(w, r, prompt, 1, len(s.options), s.selected+1) - *s.value = s.options[choice-1].Value - return nil -} - -func (s *Select) setWidth(width int) { - s.width = width - s.updateViewportSize() -} - -func (s *Select) setHeight(height int) { - s.height = height - s.updateViewportSize() -} - -func (s *Select) setPosition(first, last bool) { s.nav.setPosition(first, last) } - -var ( - confirmToggle = key.NewBinding(key.WithKeys("h", "l", "right", "left")) - confirmAccept = key.NewBinding(key.WithKeys("y", "Y")) - confirmReject = key.NewBinding(key.WithKeys("n", "N")) -) - -// Confirm is a yes/no choice. -type Confirm struct { - value *bool - title string - description string - focused bool - width int - height int - nav navKeys -} - -// NewConfirm returns a yes/no choice. -func NewConfirm() *Confirm { - return &Confirm{value: new(bool), nav: newNavKeys("enter", "tab")} + return strings.Join(lines, "\n") } -// Value binds the field to value, which also supplies the initial choice. -func (c *Confirm) Value(value *bool) *Confirm { c.value = value; return c } - -// Title sets the field title. -func (c *Confirm) Title(title string) *Confirm { c.title = title; return c } - -// Description sets the text shown under the title. -func (c *Confirm) Description(description string) *Confirm { c.description = description; return c } - -func (c *Confirm) Error() error { return nil } - -func (c *Confirm) Focus() tea.Cmd { - c.focused = true - return nil -} - -func (c *Confirm) Blur() tea.Cmd { - c.focused = false - return nil -} - -func (c *Confirm) Init() tea.Cmd { return nil } - -func (c *Confirm) Update(msg tea.Msg) (Field, tea.Cmd) { - msgKey, ok := msg.(tea.KeyPressMsg) - if !ok { - return c, nil +func renderButtons(yes, focused bool) string { + on := lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) + if focused { + on = on.Background(accent) } - switch { - case key.Matches(msgKey, confirmToggle): - *c.value = !*c.value - case key.Matches(msgKey, c.nav.prev): - return c, prevField - case key.Matches(msgKey, c.nav.next): - return c, nextField - case key.Matches(msgKey, confirmAccept): - *c.value = true - return c, nextField - case key.Matches(msgKey, confirmReject): - *c.value = false - return c, nextField + off := lipgloss.NewStyle().Padding(0, 2).Foreground(dimmed) + if yes { + return on.Render("Yes") + " " + off.Render("No") } - return c, nil + return off.Render("Yes") + " " + on.Render("No") } -func (c *Confirm) View() string { - styles := stylesFor(c.focused) - maxWidth := c.width - styles.Base.GetHorizontalFrameSize() - - var sb strings.Builder - if c.title != "" { - sb.WriteString(styles.Title.Render(wrap(c.title, maxWidth))) - } - if c.description != "" { - sb.WriteString("\n" + styles.Description.Render(wrap(c.description, maxWidth))) - } - if c.title != "" || c.description != "" { - sb.WriteString("\n\n") - } - - yes, no := styles.BlurredButton.Render("Yes"), styles.FocusedButton.Render("No") - if *c.value { - yes, no = styles.FocusedButton.Render("Yes"), styles.BlurredButton.Render("No") +// prompt asks every question as a plain line prompt. Blank answers keep the +// current value; end of input leaves the remaining answers unchanged. +func (f *Form) prompt(w io.Writer, r io.Reader) { + in := bufio.NewReader(r) + for _, page := range f.pages { + for _, field := range page.fields { + if !field.ask(w, r, in) { + fmt.Fprintln(w) + return + } + fmt.Fprintln(w) + } } - buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, no) - width := max(lipgloss.Width(buttons), lipgloss.Width(sb.String())) - sb.WriteString(lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(buttons)) - return styles.Base.Width(c.width).Height(c.height).Render(sb.String()) } -func (c *Confirm) RunAccessible(w io.Writer, r io.Reader) error { - opts := "[y/N]" - if *c.value { - opts = "[Y/n]" +// ask prompts for one field until the answer is valid. It reports false at +// end of input. +func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { + if f.title != "" { + fmt.Fprintln(w, f.title) } - prompt := stylesFor(c.focused).Title.PaddingRight(1).Render(cmp.Or(c.title, "Choose"), opts) - *c.value = promptBool(w, r, prompt, *c.value) - return nil -} - -func (c *Confirm) setWidth(width int) { c.width = width } - -func (c *Confirm) setHeight(height int) { c.height = height } - -func (c *Confirm) setPosition(first, last bool) { c.nav.setPosition(first, last) } - -type fieldStyles struct { - Base lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - ErrorIndicator lipgloss.Style - ErrorMessage lipgloss.Style - - SelectSelector lipgloss.Style - SelectedOption lipgloss.Style - UnselectedOption lipgloss.Style - - FocusedButton lipgloss.Style - BlurredButton lipgloss.Style - - TextInput struct { - Cursor lipgloss.Style - Placeholder lipgloss.Style - Prompt lipgloss.Style - Text lipgloss.Style + if f.description != "" { + fmt.Fprintln(w, f.description) } -} - -var focusedStyles, blurredStyles = newStyles() - -func newStyles() (focused, blurred fieldStyles) { - accent := lipgloss.Color("#38BDF8") - dimmed := lipgloss.Color("#64748B") - subtle := lipgloss.Color("#475569") - errColor := lipgloss.Color("#F87171") - - button := lipgloss.NewStyle().Padding(0, 2).MarginRight(1) - - focused.Base = lipgloss.NewStyle().PaddingLeft(1).BorderStyle(lipgloss.ThickBorder()).BorderLeft(true) - focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *") - focused.ErrorMessage = lipgloss.NewStyle().SetString(" *") - focused.SelectSelector = lipgloss.NewStyle().SetString("> ") - focused.FocusedButton = button.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("7")) - focused.BlurredButton = button.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("0")) - focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - - blurred = focused - blurred.Base = blurred.Base.BorderStyle(lipgloss.HiddenBorder()) - - focused.Base = focused.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(accent).PaddingLeft(1) - focused.Title = focused.Title.Foreground(accent).Bold(true) - focused.Description = focused.Description.Foreground(dimmed).Italic(true) - focused.ErrorIndicator = focused.ErrorIndicator.Foreground(errColor) - focused.ErrorMessage = focused.ErrorMessage.Foreground(errColor) - focused.SelectSelector = focused.SelectSelector.Foreground(accent).SetString("> ") - focused.SelectedOption = focused.SelectedOption.Foreground(accent).Bold(true) - focused.FocusedButton = focused.FocusedButton.Background(accent).Foreground(lipgloss.Color("#FFFFFF")) - focused.BlurredButton = focused.BlurredButton.Background(subtle) - focused.TextInput.Cursor = focused.TextInput.Cursor.Foreground(accent) - focused.TextInput.Placeholder = focused.TextInput.Placeholder.Foreground(subtle).Italic(true) - focused.TextInput.Prompt = focused.TextInput.Prompt.Foreground(accent) - - blurred.Base = blurred.Base.BorderLeft(true).BorderStyle(lipgloss.ThickBorder()).BorderForeground(subtle).PaddingLeft(1) - blurred.Title = blurred.Title.Foreground(dimmed) - blurred.Description = blurred.Description.Foreground(subtle).Italic(true) - blurred.TextInput.Text = blurred.TextInput.Text.Foreground(dimmed) - blurred.TextInput.Placeholder = blurred.TextInput.Placeholder.Foreground(subtle).Italic(true) - blurred.SelectedOption = blurred.SelectedOption.Foreground(dimmed) - blurred.SelectSelector = blurred.SelectSelector.Foreground(dimmed) - return focused, blurred -} - -func stylesFor(focused bool) *fieldStyles { - if focused { - return &focusedStyles + switch f.kind { + case kindSelect: + for i, o := range f.options { + fmt.Fprintf(w, " %d. %s\n", i+1, o.Label) + } } - return &blurredStyles -} - -// promptString prints prompt and reads a line until validate accepts it. A -// blank answer, or end of input, yields defaultValue. -func promptString(out io.Writer, in io.Reader, prompt, defaultValue string, validate func(string) error) string { - scanner := bufio.NewScanner(in) - var input string for { - fmt.Fprint(out, prompt) - if !scanner.Scan() { - fmt.Fprintln(out) - break + switch f.kind { + case kindSelect: + fmt.Fprintf(w, "Choose 1-%d [%d]: ", len(f.options), f.cursor+1) + case kindConfirm: + if *f.yes { + fmt.Fprint(w, "[Y/n]: ") + } else { + fmt.Fprint(w, "[y/N]: ") + } + default: + fmt.Fprint(w, "> ") + } + line, ok := readAnswer(w, raw, in, f.secret) + if !ok { + return false } - input = scanner.Text() - if err := validate(input); err != nil { - fmt.Fprintln(out, err) + if msg := f.answer(line); msg != "" { + fmt.Fprintln(w, msg) continue } - break + return true } - return cmp.Or(strings.TrimSpace(input), defaultValue) } -// promptInt reads a number in [low, high]; a blank answer picks defaultValue. -func promptInt(out io.Writer, in io.Reader, prompt string, low, high, defaultValue int) int { - input := promptString(out, in, prompt, strconv.Itoa(defaultValue), func(s string) error { - if strings.TrimSpace(s) == "" { - return nil +// answer applies a typed line, returning a message when it must be re-asked. +func (f *Field) answer(line string) string { + line = strings.TrimSpace(line) + switch f.kind { + case kindSelect: + if line == "" { + return "" } - if i, err := strconv.Atoi(s); err == nil && i >= low && i <= high { - return nil + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) } - if low == high { - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be %d", low) + f.cursor = n - 1 + *f.text = f.options[f.cursor].Value + case kindConfirm: + switch strings.ToLower(line) { + case "": + case "y", "yes": + *f.yes = true + case "n", "no": + *f.yes = false + default: + return "Answer y or n." } - //lint:ignore ST1005 shown to the user as a prompt line - return fmt.Errorf("Invalid: must be a number between %d and %d", low, high) - }) - choice, _ := strconv.Atoi(input) - return choice -} - -func parseBool(s string) (bool, error) { - switch strings.ToLower(s) { - case "y", "yes": - return true, nil - case "n", "no": - return false, nil + default: + value := *f.text + if line != "" { + value = line + } + if f.validate != nil { + if err := f.validate(value); err != nil { + return err.Error() + } + } + *f.text = value } - return false, errors.New("invalid input. please try again") + return "" } -// promptBool reads y/yes/n/no; a blank answer keeps defaultValue. -func promptBool(out io.Writer, in io.Reader, prompt string, defaultValue bool) bool { - def := "N" - if defaultValue { - def = "y" +// readAnswer reads one line, without echo for secrets typed at a terminal. +func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, bool) { + if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { + b, err := term.ReadPassword(int(file.Fd())) + fmt.Fprintln(w) + return string(b), err == nil } - input := promptString(out, in, prompt, def, func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - _, err := parseBool(s) - return err - }) - b, _ := parseBool(input) - return b -} - -// promptPassword reads a line from the terminal fd without echoing it. -func promptPassword(out io.Writer, fd uintptr, prompt string, validate func(string) error) (string, error) { - for { - fmt.Fprint(out, prompt) - pwd, err := term.ReadPassword(int(fd)) - if err != nil { - return "", err - } - fmt.Fprintln(out) - if err := validate(string(pwd)); err != nil { - fmt.Fprintln(out, err) - continue - } - return string(pwd), nil + line, err := in.ReadString('\n') + if err != nil && line == "" { + return "", false } + return strings.TrimRight(line, "\r\n"), true } diff --git a/zSDKs/sdk-cli/internal/forms/forms_test.go b/zSDKs/sdk-cli/internal/forms/forms_test.go new file mode 100644 index 000000000..b397ebb39 --- /dev/null +++ b/zSDKs/sdk-cli/internal/forms/forms_test.go @@ -0,0 +1,283 @@ +// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. + +package forms + +import ( + "bytes" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func press(f *Form, keys ...string) tea.Cmd { + var last tea.Cmd + for _, k := range keys { + var msg tea.KeyPressMsg + switch k { + case "enter": + msg = tea.KeyPressMsg{Code: tea.KeyEnter} + case "tab": + msg = tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + msg = tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + case "up": + msg = tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + msg = tea.KeyPressMsg{Code: tea.KeyDown} + case "left": + msg = tea.KeyPressMsg{Code: tea.KeyLeft} + case "backspace": + msg = tea.KeyPressMsg{Code: tea.KeyBackspace} + case "ctrl+c": + msg = tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + case "ctrl+j": + msg = tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} + default: + for _, r := range k { + _, last = f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } + continue + } + _, last = f.Update(msg) + } + return last +} + +func isQuit(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +func required(v string) error { + if strings.TrimSpace(v) == "" { + return errors.New("required") + } + return nil +} + +func TestFormStepsThroughPagesAndSubmits(t *testing.T) { + var token, user, format string + f := New( + NewPage("Authentication", + NewInput(&token).Title("Token").Password(), + NewInput(&user).Title("User").Placeholder("alice"), + ), + NewPage("Preferences", + NewSelect(&format, NewOption("Keep current", ""), NewOption("json", "json"), NewOption("yaml", "yaml")).Title("Format"), + ), + ) + f.Init() + + press(f, "secret", "tab", "bob", "shift+tab", "!", "enter", "enter") + if f.page != 1 { + t.Fatalf("expected the second page, got page %d", f.page) + } + if !strings.Contains(f.render(), "Preferences") || strings.Contains(f.render(), "Authentication") { + t.Fatalf("expected only the second page to render:\n%s", f.render()) + } + press(f, "down", "down", "down", "up") + if !isQuit(press(f, "enter")) { + t.Fatal("enter on the last field should submit") + } + if token != "secret!" || user != "bob" || format != "yaml" { + t.Fatalf("got token=%q user=%q format=%q", token, user, format) + } + if strings.Contains(f.render(), "secret") { + t.Fatal("password input must not render its value") + } +} + +func TestTabDoesNotSubmitFromLastField(t *testing.T) { + var v string + f := New(NewPage("", NewInput(&v))) + f.Init() + if press(f, "tab") != nil || f.finished { + t.Fatal("tab on the last field should do nothing") + } +} + +func TestValidationBlocksMovingForward(t *testing.T) { + var a, b string + f := New(NewPage("", NewInput(&a).Title("A").Validate(required), NewInput(&b).Title("B"))) + f.Init() + + press(f, "enter") + if f.field != 0 || !strings.Contains(f.render(), "required") { + t.Fatalf("expected to stay on A with an error, field=%d\n%s", f.field, f.render()) + } + press(f, "x") + if strings.Contains(f.render(), "required") { + t.Fatal("typing should clear the error") + } + press(f, "enter") + if f.field != 1 { + t.Fatalf("expected to move to B, field=%d", f.field) + } + press(f, "shift+tab") + if f.field != 0 { + t.Fatal("shift+tab should move back without validating") + } +} + +func TestTextInsertsNewlinesAndEnterMovesOn(t *testing.T) { + var body string + f := New(NewPage("", NewText(&body).Validate(required))) + f.Init() + press(f, `{"a":`, "ctrl+j", "1}") + if !isQuit(press(f, "enter")) { + t.Fatal("enter should submit the text field") + } + if body != "{\"a\":\n1}" { + t.Fatalf("got %q", body) + } +} + +func TestConfirmKeys(t *testing.T) { + var yes bool + f := New(NewPage("", NewConfirm(&yes).Title("Continue?"))) + f.Init() + press(f, "left") + if !yes { + t.Fatal("left should toggle") + } + if !isQuit(press(f, "n")) || yes { + t.Fatal("n should answer no and submit") + } + yes = false + f = New(NewPage("", NewConfirm(&yes))) + f.Init() + if !isQuit(press(f, "y")) || !yes { + t.Fatal("y should answer yes and submit") + } +} + +func TestSelectStartsAtCurrentValueAndWraps(t *testing.T) { + v := "b" + f := New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b"), NewOption("C", "c")))) + f.Init() + press(f, "down", "down") + if v != "a" { + t.Fatalf("expected the cursor to wrap to a, got %q", v) + } +} + +func TestSelectWindowFitsTerminalHeight(t *testing.T) { + var v string + options := make([]Option, 40) + for i := range options { + options[i] = NewOption("option-"+string(rune('A'+i%26)), string(rune('a'+i%26))) + } + f := New(NewPage("", NewSelect(&v, options...))) + f.Init() + f.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) + if lines := strings.Count(f.render(), "\n") + 1; lines > 12 { + t.Fatalf("rendered %d lines for a 12-line terminal", lines) + } +} + +func TestCtrlCAborts(t *testing.T) { + var v string + f := New(NewPage("", NewInput(&v))) + f.Init() + if !isQuit(press(f, "ctrl+c")) || !f.aborted { + t.Fatal("ctrl+c should abort") + } +} + +func TestPrompts(t *testing.T) { + tests := []struct { + name string + input string + build func() (*Form, func() string) + want string + out []string + }{ + { + name: "input keeps current value on blank answer", + input: "\n", + build: func() (*Form, func() string) { + v := "kept" + return New(NewPage("", NewInput(&v).Title("Name"))), func() string { return v } + }, + want: "kept", + out: []string{"Name\n> "}, + }, + { + name: "input re-asks until valid", + input: "\n \nok\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewInput(&v).Validate(required))), func() string { return v } + }, + want: "ok", + out: []string{"required\n> required\n> "}, + }, + { + name: "secret read from a pipe is not echoed", + input: "s3cret\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewInput(&v).Title("Token").Password())), func() string { return v } + }, + want: "s3cret", + }, + { + name: "select by number", + input: "9\n2\n", + build: func() (*Form, func() string) { + var v string + return New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b")).Title("Pick"))), func() string { return v } + }, + want: "b", + out: []string{"Pick\n 1. A\n 2. B\nChoose 1-2 [1]: Enter a number from 1 to 2.\n"}, + }, + { + name: "confirm", + input: "maybe\nyes\n", + build: func() (*Form, func() string) { + var v bool + return New(NewPage("", NewConfirm(&v).Title("Sure?"))), func() string { + if v { + return "yes" + } + return "no" + } + }, + want: "yes", + out: []string{"[y/N]: Answer y or n.\n"}, + }, + { + name: "end of input stops asking", + input: "first\n", + build: func() (*Form, func() string) { + var a, b string + f := New(NewPage("", NewInput(&a).Title("A"), NewInput(&b).Title("B").Validate(required))) + return f, func() string { return a + "|" + b } + }, + want: "first|", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, got := tt.build() + var out bytes.Buffer + f.prompt(&out, strings.NewReader(tt.input)) + if got() != tt.want { + t.Fatalf("got %q, want %q", got(), tt.want) + } + for _, s := range tt.out { + if !strings.Contains(out.String(), s) { + t.Fatalf("output %q does not contain %q", out.String(), s) + } + } + if strings.Contains(out.String(), "s3cret") { + t.Fatal("secret was echoed") + } + }) + } +} diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index 5c2cc80ce..31a3c1c50 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -688,9 +688,9 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalFlags { names = append(names, fields[index].Name) } - form := forms.NewForm(forms.NewGroup(forms.NewConfirm(). + form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")).Value(&fillOptional))) + Description(strings.Join(names, ", ")))) if err := form.Run(); err != nil { return nil, err } @@ -718,28 +718,23 @@ func runPromptField(field PromptField) (PromptAnswer, error) { return runRepeatableFlagPrompt(field) } - var answer PromptAnswer - var formField forms.Field + ask := func(f *forms.Field) error { + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).Run() + } + switch field.Kind { case "bool": if field.Required { value := false - formField = forms.NewConfirm().Title(field.Name).Description(field.Summary).Value(&value) - answer.Set = true - answer.Values = []string{strconv.FormatBool(value)} - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + if err := ask(forms.NewConfirm(&value)); err != nil { return PromptAnswer{}, err } - answer.Values[0] = strconv.FormatBool(value) - return answer, nil + return PromptAnswer{Set: true, Values: []string{strconv.FormatBool(value)}}, nil } value := "" - formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options( + if err := ask(forms.NewSelect(&value, forms.NewOption("Yes", "true"), forms.NewOption("No", "false"), forms.NewOption("Skip (use default)", ""), - ).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + )); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil @@ -753,79 +748,30 @@ func runPromptField(field PromptField) (PromptAnswer, error) { for _, option := range field.Options { options = append(options, forms.NewOption(option, option)) } - formField = forms.NewSelect().Title(field.Name).Description(field.Summary).Options(options...).Value(&value) - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + if err := ask(forms.NewSelect(&value, options...)); err != nil { return PromptAnswer{}, err } return PromptAnswer{Set: value != "", Values: []string{value}}, nil } value := "" - description := field.Summary - var validate func(string) error - if field.Required { - validate = func(value string) error { - if strings.TrimSpace(value) == "" { + var formField *forms.Field + if field.Kind == "json" { + formField = forms.NewText(&value) + field.Summary += " (JSON)" + } else { + formField = forms.NewInput(&value) + } + formField.Validate(func(value string) error { + if strings.TrimSpace(value) == "" { + if field.Required { return fmt.Errorf("required") } return nil } - } - switch field.Kind { - case "json": - formField = forms.NewText().Title(field.Name).Description(description + " (JSON)").Value(&value) - if validate != nil { - formField = formField.(*forms.Text).Validate(validate) - } - case "int64": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseInt(value, 10, 64); err != nil { - return fmt.Errorf("must be an integer") - } - return nil - }) - case "float64": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := strconv.ParseFloat(value, 64); err != nil { - return fmt.Errorf("must be a number") - } - return nil - }) - case "duration": - formField = forms.NewInput().Title(field.Name).Description(description).Value(&value).Validate(func(value string) error { - if strings.TrimSpace(value) == "" { - if validate != nil { - return validate(value) - } - return nil - } - if _, err := time.ParseDuration(value); err != nil { - return fmt.Errorf("must be a duration") - } - return nil - }) - default: - input := forms.NewInput().Title(field.Name).Description(description).Value(&value) - if validate != nil { - input = input.Validate(validate) - } - formField = input - } - form := forms.NewForm(forms.NewGroup(formField)) - if err := form.Run(); err != nil { + return checkPromptValue(field.Kind, value) + }) + if err := ask(formField); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { @@ -834,6 +780,25 @@ func runPromptField(field PromptField) (PromptAnswer, error) { return PromptAnswer{Set: true, Values: []string{value}}, nil } +// checkPromptValue rejects a non-empty answer that does not parse as kind. +func checkPromptValue(kind, value string) error { + switch kind { + case "int64": + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + return fmt.Errorf("must be an integer") + } + case "float64": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return fmt.Errorf("must be a number") + } + case "duration": + if _, err := time.ParseDuration(value); err != nil { + return fmt.Errorf("must be a duration") + } + } + return nil +} + func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { @@ -844,7 +809,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { title = "Add another " + displayName(strings.TrimSuffix(field.Name, " (required)"), "", false) + "?" description = "Leave empty to finish." } - input := forms.NewInput().Title(title).Description(description).Value(&value) + input := forms.NewInput(&value).Title(title).Description(description) if field.Required && len(values) == 0 { input = input.Validate(func(value string) error { if strings.TrimSpace(value) == "" { @@ -853,8 +818,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { return nil }) } - form := forms.NewForm(forms.NewGroup(input)) - if err := form.Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { From 4887585231bd2a390fd665e380c515eca44d7e62 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 00:09:57 +0100 Subject: [PATCH 4/9] fix(cli): harden the forms package and test it against huh's scenarios Form fixes: - Line prompts read and write through Form.IO, so configure and auth login use the command's streams; prompts for missing inputs write to stderr to keep stdout for command output. - Read errors other than end of input are returned from Run instead of being treated as a finished form. - Answers keep their surrounding whitespace; only blank answers fall back to the current value. - Enter on the last field submits even when empty pages follow it. - Page titles and option labels wrap to the terminal width, and multi-line option labels stay aligned under their first line. - Selects window options by rendered height and support ctrl+d and ctrl+u half-page moves. The tests move from internal/forms into tests/primary so they run against the generated test CLI without shipping in generated output. They cover the scenarios huh's own suite exercises for the fields used here: multi-page walk-through with validation, input, password, placeholder and paste, text with newlines, confirm keys, select navigation including page moves and wrap-around, previous page, titles and descriptions, width and resize, abort, and line prompts for every field kind including defaults, invalid answers, piped multi-field input, end of input and read errors. --- templates/perms.go | 2 +- .../auxiliary/internal/forms/forms.go.stmpl | 174 +++-- .../internal/forms/forms_test.go.stmpl | 283 ------- .../internal/interactive/interactive.go.stmpl | 20 +- templates/templates/cli/configure.go.stmpl | 2 +- templates/templates/cli/includes/security.ts | 6 +- .../cli/tests/primary/forms_test.go.stmpl | 705 ++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 10 +- zSDKs/sdk-cli/internal/cli/auth.go | 16 +- zSDKs/sdk-cli/internal/cli/configure.go | 2 +- zSDKs/sdk-cli/internal/forms/forms.go | 174 +++-- zSDKs/sdk-cli/internal/forms/forms_test.go | 283 ------- .../internal/interactive/interactive.go | 20 +- 13 files changed, 971 insertions(+), 726 deletions(-) delete mode 100644 templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl create mode 100644 templates/templates/cli/tests/primary/forms_test.go.stmpl delete mode 100644 zSDKs/sdk-cli/internal/forms/forms_test.go diff --git a/templates/perms.go b/templates/perms.go index 34c05bebc..e8e22ab51 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -43,7 +43,6 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/auxiliary/internal/flagutil/preset.go.stmpl": 0o644, "templates/cli/auxiliary/internal/forms": 0o20000000755, "templates/cli/auxiliary/internal/forms/forms.go.stmpl": 0o644, - "templates/cli/auxiliary/internal/forms/forms_test.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive": 0o20000000755, "templates/cli/auxiliary/internal/interactive/interactive.go.stmpl": 0o644, "templates/cli/auxiliary/internal/interactive/policy.go.stmpl": 0o644, @@ -190,6 +189,7 @@ var fileMode = map[string]fs.FileMode{ "templates/cli/tests/primary/exitcodes_test.go.stmpl": 0o644, "templates/cli/tests/primary/flagutilmeta_test.go.stmpl": 0o644, "templates/cli/tests/primary/flattening_test.go.stmpl": 0o644, + "templates/cli/tests/primary/forms_test.go.stmpl": 0o644, "templates/cli/tests/primary/globals_additional_test.go.stmpl": 0o644, "templates/cli/tests/primary/headers_additional_test.go.stmpl": 0o644, "templates/cli/tests/primary/hooks_additional_test.go.stmpl": 0o644, diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl index 7eb0ea9f7..38c4cdf5e 100644 --- a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -63,8 +63,9 @@ type Field struct { yes *bool // confirm answer secret bool - options []Option - cursor int + options []Option + cursor int + halfPage int // rows moved by ctrl+d and ctrl+u, set from the visible window input textinput.Model area textarea.Model @@ -197,6 +198,10 @@ func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { f.cursor = 0 case "end", "G": f.cursor = n - 1 + case "ctrl+d": + f.cursor = min(f.cursor+f.halfPage, n-1) + case "ctrl+u": + f.cursor = max(f.cursor-f.halfPage, 0) } *f.text = f.options[f.cursor].Value case kindConfirm: @@ -235,6 +240,8 @@ type Form struct { page int field int accessible bool + in io.Reader + out io.Writer width int height int finished bool @@ -243,7 +250,7 @@ type Form struct { // New returns a form over pages. Dumb terminals default to line prompts. func New(pages ...*Page) *Form { - f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb"} + f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb", in: os.Stdin, out: os.Stdout} f.resize(terminalWidth(), 0) return f } @@ -254,6 +261,13 @@ func (f *Form) Accessible(on bool) *Form { return f } +// IO sets where line prompts read answers and write questions; the +// interactive form always uses the terminal. +func (f *Form) IO(in io.Reader, out io.Writer) *Form { + f.in, f.out = in, out + return f +} + func terminalWidth() int { if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { return width @@ -268,8 +282,7 @@ func (f *Form) Run() error { return nil } if f.accessible { - f.prompt(os.Stdout, os.Stdin) - return nil + return f.prompt(f.out, f.in) } if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { if errors.Is(err, tea.ErrInterrupted) { @@ -306,37 +319,37 @@ func (f *Form) current() *Field { } func (f *Form) onLastField() bool { - return f.page == len(f.pages)-1 && f.field == len(f.pages[f.page].fields)-1 + _, _, ok := f.neighbour(1) + return !ok } -// step moves the focus by one field across page boundaries, skipping empty -// pages. It reports false when there is no field in that direction. -func (f *Form) step(delta int) bool { - page, field := f.page, f.field+delta +// neighbour finds the field one step away across page boundaries, skipping +// empty pages. ok is false when there is no field in that direction. +func (f *Form) neighbour(delta int) (page, field int, ok bool) { + page, field = f.page, f.field+delta for { switch { case field < 0: - page-- - if page < 0 { - return false + if page--; page < 0 { + return 0, 0, false } field = len(f.pages[page].fields) - 1 - continue case field >= len(f.pages[page].fields): - page++ - if page >= len(f.pages) { - return false + if page++; page >= len(f.pages) { + return 0, 0, false } field = 0 - continue + default: + return page, field, true } - f.page, f.field = page, field - return true } } // Init implements [tea.Model]. func (f *Form) Init() tea.Cmd { + if f.empty() { + return nil + } for f.field >= len(f.pages[f.page].fields) { f.page++ } @@ -386,11 +399,12 @@ func (f *Form) forward() tea.Cmd { } func (f *Form) move(delta int) tea.Cmd { - prev := f.current() - if !f.step(delta) { + page, field, ok := f.neighbour(delta) + if !ok { return nil } - prev.blur() + f.current().blur() + f.page, f.field = page, field return f.current().focus() } @@ -406,7 +420,8 @@ func (f *Form) render() string { page := f.pages[f.page] var b strings.Builder if page.title != "" { - b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(page.title) + "\n\n") + title := wrap(page.title, max(f.width, 10)) + b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title) + "\n\n") } for i, field := range page.fields { if i > 0 { @@ -425,10 +440,10 @@ func (f *Form) renderField(field *Field, focused bool) string { } lines := []string{} if field.title != "" { - lines = append(lines, title.Render(lipgloss.Wrap(field.title, inner, " "))) + lines = append(lines, title.Render(wrap(field.title, inner))) } if field.description != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(lipgloss.Wrap(field.description, inner, " "))) + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) } switch field.kind { case kindInput: @@ -449,30 +464,53 @@ func (f *Form) renderField(field *Field, focused bool) string { Render(strings.Join(lines, "\n")) } -// renderOptions shows a window of options around the cursor that fits the -// terminal height. +// renderOptions shows the options around the cursor that fit the terminal +// height. Multi-line labels stay aligned under their first line. func (f *Form) renderOptions(field *Field, focused bool) string { - rows := len(field.options) - if f.height > 0 { - rows = min(rows, max(3, f.height-8)) - } - start := min(max(field.cursor-rows/2, 0), len(field.options)-rows) picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - var lines []string - for i := start; i < start+rows; i++ { + labelWidth := max(f.width-6, 4) + blocks := make([]string, len(field.options)) + for i, o := range field.options { + label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") if i == field.cursor { - lines = append(lines, picked.Render("> "+field.options[i].Label)) + blocks[i] = picked.Render("> " + label) } else { - lines = append(lines, " "+field.options[i].Label) + blocks[i] = " " + label + } + } + + budget := 0 + if f.height > 0 { + budget = max(3, f.height-8) + } + start, end := 0, len(blocks) + if budget > 0 && len(blocks) > 0 { + start, end = field.cursor, field.cursor+1 + used := lipgloss.Height(blocks[field.cursor]) + for grew := true; grew; { + grew = false + if end < len(blocks) && used+lipgloss.Height(blocks[end]) <= budget { + used += lipgloss.Height(blocks[end]) + end++ + grew = true + } + if start > 0 && used+lipgloss.Height(blocks[start-1]) <= budget { + start-- + used += lipgloss.Height(blocks[start]) + grew = true + } } } - if rows < len(field.options) { - lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(field.options)))) + field.halfPage = max((end-start)/2, 1) + + out := strings.Join(blocks[start:end], "\n") + if start > 0 || end < len(blocks) { + out += "\n" + lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(blocks))) } - return strings.Join(lines, "\n") + return out } func renderButtons(yes, focused bool) string { @@ -489,22 +527,26 @@ func renderButtons(yes, focused bool) string { // prompt asks every question as a plain line prompt. Blank answers keep the // current value; end of input leaves the remaining answers unchanged. -func (f *Form) prompt(w io.Writer, r io.Reader) { +func (f *Form) prompt(w io.Writer, r io.Reader) error { in := bufio.NewReader(r) for _, page := range f.pages { for _, field := range page.fields { - if !field.ask(w, r, in) { - fmt.Fprintln(w) - return - } + err := field.ask(w, r, in) fmt.Fprintln(w) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } } } + return nil } -// ask prompts for one field until the answer is valid. It reports false at +// ask prompts for one field until the answer is valid. It returns io.EOF at // end of input. -func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { +func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { if f.title != "" { fmt.Fprintln(w, f.title) } @@ -530,26 +572,27 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { default: fmt.Fprint(w, "> ") } - line, ok := readAnswer(w, raw, in, f.secret) - if !ok { - return false + line, err := readAnswer(w, raw, in, f.secret) + if err != nil { + return err } if msg := f.answer(line); msg != "" { fmt.Fprintln(w, msg) continue } - return true + return nil } } // answer applies a typed line, returning a message when it must be re-asked. func (f *Field) answer(line string) string { - line = strings.TrimSpace(line) + blank := strings.TrimSpace(line) == "" switch f.kind { case kindSelect: - if line == "" { + if blank { return "" } + line = strings.TrimSpace(line) n, err := strconv.Atoi(line) if err != nil || n < 1 || n > len(f.options) { return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) @@ -557,7 +600,7 @@ func (f *Field) answer(line string) string { f.cursor = n - 1 *f.text = f.options[f.cursor].Value case kindConfirm: - switch strings.ToLower(line) { + switch strings.ToLower(strings.TrimSpace(line)) { case "": case "y", "yes": *f.yes = true @@ -568,7 +611,7 @@ func (f *Field) answer(line string) string { } default: value := *f.text - if line != "" { + if !blank { value = line } if f.validate != nil { @@ -582,16 +625,27 @@ func (f *Field) answer(line string) string { } // readAnswer reads one line, without echo for secrets typed at a terminal. -func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, bool) { +// It returns io.EOF only when no answer was read. +func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, error) { if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { b, err := term.ReadPassword(int(file.Fd())) fmt.Fprintln(w) - return string(b), err == nil + return string(b), err } line, err := in.ReadString('\n') - if err != nil && line == "" { - return "", false + if err != nil && (line == "" || !errors.Is(err, io.EOF)) { + return "", err } - return strings.TrimRight(line, "\r\n"), true + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +// wrap breaks s at spaces to fit width, without trailing spaces that some +// terminals scroll on in the last column. +func wrap(s string, width int) string { + lines := strings.Split(lipgloss.Wrap(s, width, " "), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.Join(lines, "\n") } {{- end}} diff --git a/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl deleted file mode 100644 index 72782663c..000000000 --- a/templates/templates/cli/auxiliary/internal/forms/forms_test.go.stmpl +++ /dev/null @@ -1,283 +0,0 @@ -{{- if isAnyInteractiveEnabled}} -package forms - -import ( - "bytes" - "errors" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" -) - -func press(f *Form, keys ...string) tea.Cmd { - var last tea.Cmd - for _, k := range keys { - var msg tea.KeyPressMsg - switch k { - case "enter": - msg = tea.KeyPressMsg{Code: tea.KeyEnter} - case "tab": - msg = tea.KeyPressMsg{Code: tea.KeyTab} - case "shift+tab": - msg = tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} - case "up": - msg = tea.KeyPressMsg{Code: tea.KeyUp} - case "down": - msg = tea.KeyPressMsg{Code: tea.KeyDown} - case "left": - msg = tea.KeyPressMsg{Code: tea.KeyLeft} - case "backspace": - msg = tea.KeyPressMsg{Code: tea.KeyBackspace} - case "ctrl+c": - msg = tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} - case "ctrl+j": - msg = tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} - default: - for _, r := range k { - _, last = f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) - } - continue - } - _, last = f.Update(msg) - } - return last -} - -func isQuit(cmd tea.Cmd) bool { - if cmd == nil { - return false - } - _, ok := cmd().(tea.QuitMsg) - return ok -} - -func required(v string) error { - if strings.TrimSpace(v) == "" { - return errors.New("required") - } - return nil -} - -func TestFormStepsThroughPagesAndSubmits(t *testing.T) { - var token, user, format string - f := New( - NewPage("Authentication", - NewInput(&token).Title("Token").Password(), - NewInput(&user).Title("User").Placeholder("alice"), - ), - NewPage("Preferences", - NewSelect(&format, NewOption("Keep current", ""), NewOption("json", "json"), NewOption("yaml", "yaml")).Title("Format"), - ), - ) - f.Init() - - press(f, "secret", "tab", "bob", "shift+tab", "!", "enter", "enter") - if f.page != 1 { - t.Fatalf("expected the second page, got page %d", f.page) - } - if !strings.Contains(f.render(), "Preferences") || strings.Contains(f.render(), "Authentication") { - t.Fatalf("expected only the second page to render:\n%s", f.render()) - } - press(f, "down", "down", "down", "up") - if !isQuit(press(f, "enter")) { - t.Fatal("enter on the last field should submit") - } - if token != "secret!" || user != "bob" || format != "yaml" { - t.Fatalf("got token=%q user=%q format=%q", token, user, format) - } - if strings.Contains(f.render(), "secret") { - t.Fatal("password input must not render its value") - } -} - -func TestTabDoesNotSubmitFromLastField(t *testing.T) { - var v string - f := New(NewPage("", NewInput(&v))) - f.Init() - if press(f, "tab") != nil || f.finished { - t.Fatal("tab on the last field should do nothing") - } -} - -func TestValidationBlocksMovingForward(t *testing.T) { - var a, b string - f := New(NewPage("", NewInput(&a).Title("A").Validate(required), NewInput(&b).Title("B"))) - f.Init() - - press(f, "enter") - if f.field != 0 || !strings.Contains(f.render(), "required") { - t.Fatalf("expected to stay on A with an error, field=%d\n%s", f.field, f.render()) - } - press(f, "x") - if strings.Contains(f.render(), "required") { - t.Fatal("typing should clear the error") - } - press(f, "enter") - if f.field != 1 { - t.Fatalf("expected to move to B, field=%d", f.field) - } - press(f, "shift+tab") - if f.field != 0 { - t.Fatal("shift+tab should move back without validating") - } -} - -func TestTextInsertsNewlinesAndEnterMovesOn(t *testing.T) { - var body string - f := New(NewPage("", NewText(&body).Validate(required))) - f.Init() - press(f, `{"a":`, "ctrl+j", "1}") - if !isQuit(press(f, "enter")) { - t.Fatal("enter should submit the text field") - } - if body != "{\"a\":\n1}" { - t.Fatalf("got %q", body) - } -} - -func TestConfirmKeys(t *testing.T) { - var yes bool - f := New(NewPage("", NewConfirm(&yes).Title("Continue?"))) - f.Init() - press(f, "left") - if !yes { - t.Fatal("left should toggle") - } - if !isQuit(press(f, "n")) || yes { - t.Fatal("n should answer no and submit") - } - yes = false - f = New(NewPage("", NewConfirm(&yes))) - f.Init() - if !isQuit(press(f, "y")) || !yes { - t.Fatal("y should answer yes and submit") - } -} - -func TestSelectStartsAtCurrentValueAndWraps(t *testing.T) { - v := "b" - f := New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b"), NewOption("C", "c")))) - f.Init() - press(f, "down", "down") - if v != "a" { - t.Fatalf("expected the cursor to wrap to a, got %q", v) - } -} - -func TestSelectWindowFitsTerminalHeight(t *testing.T) { - var v string - options := make([]Option, 40) - for i := range options { - options[i] = NewOption("option-"+string(rune('A'+i%26)), string(rune('a'+i%26))) - } - f := New(NewPage("", NewSelect(&v, options...))) - f.Init() - f.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) - if lines := strings.Count(f.render(), "\n") + 1; lines > 12 { - t.Fatalf("rendered %d lines for a 12-line terminal", lines) - } -} - -func TestCtrlCAborts(t *testing.T) { - var v string - f := New(NewPage("", NewInput(&v))) - f.Init() - if !isQuit(press(f, "ctrl+c")) || !f.aborted { - t.Fatal("ctrl+c should abort") - } -} - -func TestPrompts(t *testing.T) { - tests := []struct { - name string - input string - build func() (*Form, func() string) - want string - out []string - }{ - { - name: "input keeps current value on blank answer", - input: "\n", - build: func() (*Form, func() string) { - v := "kept" - return New(NewPage("", NewInput(&v).Title("Name"))), func() string { return v } - }, - want: "kept", - out: []string{"Name\n> "}, - }, - { - name: "input re-asks until valid", - input: "\n \nok\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewInput(&v).Validate(required))), func() string { return v } - }, - want: "ok", - out: []string{"required\n> required\n> "}, - }, - { - name: "secret read from a pipe is not echoed", - input: "s3cret\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewInput(&v).Title("Token").Password())), func() string { return v } - }, - want: "s3cret", - }, - { - name: "select by number", - input: "9\n2\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b")).Title("Pick"))), func() string { return v } - }, - want: "b", - out: []string{"Pick\n 1. A\n 2. B\nChoose 1-2 [1]: Enter a number from 1 to 2.\n"}, - }, - { - name: "confirm", - input: "maybe\nyes\n", - build: func() (*Form, func() string) { - var v bool - return New(NewPage("", NewConfirm(&v).Title("Sure?"))), func() string { - if v { - return "yes" - } - return "no" - } - }, - want: "yes", - out: []string{"[y/N]: Answer y or n.\n"}, - }, - { - name: "end of input stops asking", - input: "first\n", - build: func() (*Form, func() string) { - var a, b string - f := New(NewPage("", NewInput(&a).Title("A"), NewInput(&b).Title("B").Validate(required))) - return f, func() string { return a + "|" + b } - }, - want: "first|", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f, got := tt.build() - var out bytes.Buffer - f.prompt(&out, strings.NewReader(tt.input)) - if got() != tt.want { - t.Fatalf("got %q, want %q", got(), tt.want) - } - for _, s := range tt.out { - if !strings.Contains(out.String(), s) { - t.Fatalf("output %q does not contain %q", out.String(), s) - } - } - if strings.Contains(out.String(), "s3cret") { - t.Fatal("secret was echoed") - } - }) - } -} -{{- end}} diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index 3fb5c9e89..0811d8b2c 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -657,7 +657,7 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) for _, index := range required { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -666,14 +666,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field) + answer, err := runPromptField(cmd, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -689,13 +689,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")))) + Description(strings.Join(names, ", ")))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -712,13 +712,13 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field) + return runRepeatableFlagPrompt(cmd, field) } ask := func(f *forms.Field) error { - return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).Run() + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run() } switch field.Kind { @@ -798,7 +798,7 @@ func checkPromptValue(kind, value string) error { return nil } -func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { +func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -817,7 +817,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { return nil }) } - if err := forms.New(forms.NewPage("", input)).Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index 9d0cfd2ca..1bdfeeb96 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -101,7 +101,7 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := forms.New(pages...).Accessible(accessible) + form := forms.New(pages...).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 55516a612..00f642e67 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2542,7 +2542,7 @@ function templateAuthLoginBody(): string { lines.push(`// #endregion custom-auth-groups`); lines.push(``); lines.push( - `form := forms.New(forms.NewPage("", fields...)).Accessible(accessible)`, + `form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout())`, ); lines.push(``); lines.push(`if err := form.Run(); err != nil {`); @@ -2568,7 +2568,7 @@ function templateAuthLoginBody(): string { lines.push(` Description("Choose which credentials to configure")`); lines.push(``); lines.push( - `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).Run(); err != nil {`, + `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2589,7 +2589,7 @@ function templateAuthLoginBody(): string { lines.push(` }`); lines.push(``); lines.push( - ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible)`, + ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout())`, ); lines.push(``); lines.push(` if err := form.Run(); err != nil {`); diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl new file mode 100644 index 000000000..1f1ab6c1a --- /dev/null +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -0,0 +1,705 @@ +{{- if isAnyInteractiveEnabled}} +package tests + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "{{getGolangPackage}}/internal/forms" +) + +var formsANSI = regexp.MustCompile(`\x1b\[[0-9;:?]*[a-zA-Z]`) + +func formsView(f *forms.Form) string { + return formsANSI.ReplaceAllString(f.View().Content, "") +} + +func formsStart(t *testing.T, width, height int, pages ...*forms.Page) *forms.Form { + t.Helper() + f := forms.New(pages...) + f.Init() + f.Update(tea.WindowSizeMsg{Width: width, Height: height}) + return f +} + +func formsKey(k string) tea.KeyPressMsg { + switch k { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "alt+enter": + return tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} + case "tab": + return tea.KeyPressMsg{Code: tea.KeyTab} + case "shift+tab": + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + case "up": + return tea.KeyPressMsg{Code: tea.KeyUp} + case "down": + return tea.KeyPressMsg{Code: tea.KeyDown} + case "left": + return tea.KeyPressMsg{Code: tea.KeyLeft} + case "right": + return tea.KeyPressMsg{Code: tea.KeyRight} + case "home": + return tea.KeyPressMsg{Code: tea.KeyHome} + case "end": + return tea.KeyPressMsg{Code: tea.KeyEnd} + case "backspace": + return tea.KeyPressMsg{Code: tea.KeyBackspace} + } + if mod, r, ok := strings.Cut(k, "+"); ok && mod == "ctrl" { + return tea.KeyPressMsg{Code: rune(r[0]), Mod: tea.ModCtrl} + } + return tea.KeyPressMsg{Code: rune(k[0]), Text: k} +} + +// formsPress sends keys and returns the command produced by the last one. +func formsPress(f *forms.Form, keys ...string) tea.Cmd { + var cmd tea.Cmd + for _, k := range keys { + _, cmd = f.Update(formsKey(k)) + } + return cmd +} + +func formsType(f *forms.Form, s string) { + for _, r := range s { + f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + } +} + +// formsQuits reports whether cmd, or any command batched inside it, formsQuits. +func formsQuits(cmd tea.Cmd) bool { + if cmd == nil { + return false + } + switch msg := cmd().(type) { + case tea.QuitMsg: + return true + case tea.BatchMsg: + for _, c := range msg { + if formsQuits(c) { + return true + } + } + } + return false +} + +func formsContains(t *testing.T, s, want string) { + t.Helper() + if !strings.Contains(s, want) { + t.Fatalf("expected %q in:\n%s", want, s) + } +} + +func formsNotContains(t *testing.T, s, unwanted string) { + t.Helper() + if strings.Contains(s, unwanted) { + t.Fatalf("did not expect %q in:\n%s", unwanted, s) + } +} + +func formsRequired(v string) error { + if strings.TrimSpace(v) == "" { + return errors.New("required") + } + return nil +} + +func TestFormsFormWalksPagesAndCollectsAnswers(t *testing.T) { + var shell, base, name, instructions string + var discount bool + f := formsStart(t, 80, 40, + forms.NewPage("Taco", + forms.NewSelect(&shell, forms.NewOption("Soft", "Soft"), forms.NewOption("Hard", "Hard")). + Title("Shell?"). + Description("Our tortillas are made fresh in-house every day."). + Validate(func(v string) error { + if v == "Hard" { + return errors.New("we're out of hard shells, sorry") + } + return nil + }), + forms.NewSelect(&base, forms.NewOption("Chicken", "Chicken"), forms.NewOption("Beef", "Beef")).Title("Base"), + ), + forms.NewPage("Details", + forms.NewInput(&name).Title("What's your name?").Placeholder("Margaret").Description("For when your order is ready."), + forms.NewText(&instructions).Title("Special Instructions").Description("Anything we should know?"), + forms.NewConfirm(&discount).Title("Would you like 15% off?"), + ), + ) + + v := formsView(f) + formsContains(t, v, "┃ Shell?") + formsContains(t, v, "Our tortillas are made fresh in-house every day.") + formsContains(t, v, "Base") + formsNotContains(t, v, "What's your name?") + + formsPress(f, "j", "tab") + formsContains(t, formsView(f), "we're out of hard shells, sorry") + + formsPress(f, "k") + formsNotContains(t, formsView(f), "we're out of hard shells") + formsPress(f, "enter") + formsContains(t, formsView(f), "┃ > Chicken") + + formsPress(f, "enter") + v = formsView(f) + formsContains(t, v, "What's your name?") + formsContains(t, v, "Special Instructions") + formsContains(t, v, "Would you like 15% off?") + formsNotContains(t, v, "Shell?") + + formsType(f, "Glen") + formsContains(t, formsView(f), "Glen") + formsPress(f, "enter") + formsType(f, "Ring twice") + formsPress(f, "enter") + if !formsQuits(formsPress(f, "y")) { + t.Fatal("answering the last confirm should submit") + } + if formsView(f) != "" { + t.Fatal("a submitted form should render nothing") + } + if shell != "Soft" || base != "Chicken" || name != "Glen" || instructions != "Ring twice" || !discount { + t.Fatalf("got shell=%q base=%q name=%q instructions=%q discount=%v", shell, base, name, instructions, discount) + } +} + +func TestFormsInput(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v))) + formsContains(t, formsView(f), ">") + formsType(f, "Huh") + formsContains(t, formsView(f), "Huh") + if v != "Huh" { + t.Fatalf("got %q", v) + } + formsPress(f, "backspace") + if v != "Hu" { + t.Fatalf("backspace: got %q", v) + } +} + +func TestFormsInputStartsFromBoundValue(t *testing.T) { + v := "existing" + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v))) + formsContains(t, formsView(f), "existing") + formsType(f, "!") + if v != "existing!" { + t.Fatalf("got %q", v) + } +} + +func TestFormsInputPlaceholderAndPassword(t *testing.T) { + var user, token string + f := formsStart(t, 80, 24, forms.NewPage("", + forms.NewInput(&token).Title("Token").Password().Placeholder("sk_****45"), + forms.NewInput(&user).Title("User").Placeholder("alice"), + )) + v := formsView(f) + formsContains(t, v, "sk_****45") + formsContains(t, v, "alice") + formsType(f, "hunter2") + formsNotContains(t, formsView(f), "hunter2") + if token != "hunter2" { + t.Fatalf("got %q", token) + } +} + +func TestFormsPasteNotDuplicated(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v).Title("Name"))) + f.Update(tea.PasteMsg{Content: "hello"}) + if v != "hello" { + t.Fatalf("expected %q, got %q", "hello", v) + } +} + +func TestFormsText(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewText(&v).Title("Body"))) + formsType(f, `{"a":`) + formsPress(f, "ctrl+j") + formsType(f, "1,") + formsPress(f, "alt+enter") + formsType(f, `"b":2}`) + formsContains(t, formsView(f), `"b":2}`) + if v != "{\"a\":\n1,\n\"b\":2}" { + t.Fatalf("got %q", v) + } + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter should submit a text field") + } +} + +func TestFormsTextPaste(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewText(&v))) + f.Update(tea.PasteMsg{Content: "line one\nline two"}) + if v != "line one\nline two" { + t.Fatalf("got %q", v) + } +} + +func TestFormsConfirm(t *testing.T) { + var v bool + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewConfirm(&v).Title("Are you sure?"))) + view := formsView(f) + formsContains(t, view, "Yes") + formsContains(t, view, "No") + formsContains(t, view, "Are you sure?") + if v { + t.Fatal("expected false") + } + formsPress(f, "left") + if !v { + t.Fatal("left should toggle to true") + } + formsPress(f, "right") + if v { + t.Fatal("right should toggle back to false") + } + formsPress(f, "h") + if !v { + t.Fatal("h should toggle to true") + } + formsPress(f, "l") + if v { + t.Fatal("l should toggle back to false") + } + if !formsQuits(formsPress(f, "enter")) || v { + t.Fatal("enter should submit the current choice") + } +} + +func TestFormsConfirmAnswerKeys(t *testing.T) { + for _, tt := range []struct { + key string + from bool + want bool + }{ + {"y", false, true}, + {"Y", false, true}, + {"n", true, false}, + {"N", true, false}, + } { + t.Run(tt.key, func(t *testing.T) { + v := tt.from + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewConfirm(&v))) + if !formsQuits(formsPress(f, tt.key)) || v != tt.want { + t.Fatalf("%s: quit and value %v expected, got %v", tt.key, tt.want, v) + } + }) + } +} + +func TestFormsSelect(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, + forms.NewOption("Foo\nLine 2", "foo"), + forms.NewOption("Bar\nLine 2", "bar"), + forms.NewOption("Baz\nLine 2", "baz"), + forms.NewOption("Ban\nLine 2", "ban"), + ).Title("Which one?"))) + + view := formsView(f) + formsContains(t, view, "Which one?") + formsContains(t, view, "> Foo") + formsContains(t, view, "┃ Line 2") + if v != "foo" { + t.Fatalf("the first option should be selected initially, got %q", v) + } + + formsPress(f, "down") + view = formsView(f) + formsNotContains(t, view, "> Foo") + formsContains(t, view, "> Bar") + if !formsQuits(formsPress(f, "enter")) || v != "bar" { + t.Fatalf("expected bar, got %q", v) + } +} + +func TestFormsSelectStartsAtBoundValue(t *testing.T) { + v := "c" + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, + forms.NewOption("A", "a"), forms.NewOption("B", "b"), forms.NewOption("C", "c")))) + formsContains(t, formsView(f), "> C") + formsPress(f, "down") + if v != "a" { + t.Fatalf("down from the last option should wrap to the first, got %q", v) + } + formsPress(f, "up") + if v != "c" { + t.Fatalf("up from the first option should wrap to the last, got %q", v) + } + formsPress(f, "k", "k") + if v != "a" { + t.Fatalf("k should move up, got %q", v) + } + formsPress(f, "j", "ctrl+n", "ctrl+p") + if v != "b" { + t.Fatalf("j, ctrl+n, ctrl+p: got %q", v) + } +} + +func TestFormsSelectUnknownBoundValuePicksFirst(t *testing.T) { + v := "missing" + formsStart(t, 80, 24, forms.NewPage("", forms.NewSelect(&v, forms.NewOption("A", "a"), forms.NewOption("B", "b")))) + if v != "a" { + t.Fatalf("got %q", v) + } +} + +func TestFormsSelectPageNavigation(t *testing.T) { + names := []string{"Qux", "Quux", "Foo", "Bar", "Baz", "Corge", "Grault", "Garply", "Waldo", "Fred", + "Plugh", "Xyzzy", "Thud", "Norf", "Blip", "Flob", "Zorp", "Smurf", "Bloop", "Ping"} + options := make([]forms.Option, len(names)) + for i, n := range names { + options[i] = forms.NewOption(n, n) + } + var v string + f := formsStart(t, 80, 16, forms.NewPage("", forms.NewSelect(&v, options...).Title("Choose"))) + + visible := func() []string { + var shown []string + for _, n := range names { + if regexp.MustCompile(`(?m)^┃ [> ] ` + n + `$`).MatchString(formsView(f)) { + shown = append(shown, n) + } + } + return shown + } + if len(visible()) >= len(names) { + t.Fatal("expected a window smaller than the option list") + } + formsContains(t, formsView(f), "> Qux") + + formsPress(f, "G") + formsContains(t, formsView(f), "> Ping") + formsPress(f, "g") + formsContains(t, formsView(f), "> Qux") + formsPress(f, "end") + formsContains(t, formsView(f), "> Ping") + formsPress(f, "home") + formsContains(t, formsView(f), "> Qux") + + formsPress(f, "ctrl+d") + if v == "Qux" || v == "Ping" { + t.Fatalf("ctrl+d should move half a window, got %q", v) + } + formsContains(t, formsView(f), "> "+v) + + formsPress(f, "end", "ctrl+u") + if v == "Qux" || v == "Ping" { + t.Fatalf("ctrl+u should move half a window, got %q", v) + } + formsContains(t, formsView(f), "> "+v) + for range 30 { + formsPress(f, "ctrl+u") + } + if v != "Qux" { + t.Fatalf("ctrl+u should stop at the first option, got %q", v) + } + for range 30 { + formsPress(f, "ctrl+d") + } + if v != "Ping" { + t.Fatalf("ctrl+d should stop at the last option, got %q", v) + } + + for i := range names { + formsPress(f, "down") + want := names[i] + formsContains(t, formsView(f), "> "+want) + } +} + +func TestFormsSelectWindowFitsTerminal(t *testing.T) { + options := make([]forms.Option, 40) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("option %d\nsecond line", i), fmt.Sprint(i)) + } + var v string + for _, height := range []int{8, 12, 24} { + f := formsStart(t, 80, height, forms.NewPage("", forms.NewSelect(&v, options...).Title("Pick"))) + for range 25 { + formsPress(f, "down") + if got := lipgloss.Height(formsView(f)); got > height { + t.Fatalf("height %d: rendered %d lines", height, got) + } + formsContains(t, formsView(f), "> option "+v) + } + } +} + +func TestFormsPreviousPage(t *testing.T) { + var a, b, c string + f := formsStart(t, 80, 24, + forms.NewPage("First", forms.NewInput(&a).Title("Bar")), + forms.NewPage("Second", forms.NewInput(&b).Title("Foo")), + forms.NewPage("Third", forms.NewInput(&c).Title("Baz")), + ) + formsPress(f, "enter", "enter") + formsContains(t, formsView(f), "Baz") + formsPress(f, "shift+tab", "shift+tab") + v := formsView(f) + formsContains(t, v, "Bar") + formsNotContains(t, v, "Baz") + formsPress(f, "shift+tab") + formsContains(t, formsView(f), "Bar") +} + +func TestFormsTabDoesNotSubmit(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&a).Title("A"), forms.NewInput(&b).Title("B"))) + formsPress(f, "tab") + if formsQuits(formsPress(f, "tab")) || formsView(f) == "" { + t.Fatal("tab on the last field should not submit") + } + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter on the last field should submit") + } +} + +func TestFormsEmptyPagesAreSkipped(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, + forms.NewPage("Leading"), + forms.NewPage("One", forms.NewInput(&a).Title("A")), + forms.NewPage("Middle"), + forms.NewPage("Two", forms.NewInput(&b).Title("B")), + forms.NewPage("Trailing"), + ) + formsContains(t, formsView(f), "One") + formsPress(f, "enter") + formsContains(t, formsView(f), "Two") + if !formsQuits(formsPress(f, "enter")) { + t.Fatal("enter on the last real field should submit despite a trailing empty page") + } +} + +func TestFormsValidationBlocksForwardOnly(t *testing.T) { + var a, b string + f := formsStart(t, 80, 24, forms.NewPage("", + forms.NewInput(&a).Title("A").Validate(formsRequired), + forms.NewText(&b).Title("B").Validate(formsRequired), + )) + formsPress(f, "enter") + formsContains(t, formsView(f), "required") + formsPress(f, "tab") + formsContains(t, formsView(f), "required") + formsType(f, "x") + formsNotContains(t, formsView(f), "required") + formsPress(f, "enter") + if formsQuits(formsPress(f, "enter")) { + t.Fatal("an invalid last field must not submit") + } + formsContains(t, formsView(f), "required") + formsPress(f, "shift+tab") + if a != "x" { + t.Fatalf("got %q", a) + } + formsPress(f, "backspace", "tab") + formsContains(t, formsView(f), "required") +} + +func TestFormsCtrlCAborts(t *testing.T) { + var v string + f := formsStart(t, 80, 24, forms.NewPage("", forms.NewInput(&v).Title("Foo"))) + if !formsQuits(formsPress(f, "ctrl+c")) { + t.Fatal("ctrl+c should quit") + } + if formsView(f) != "" { + t.Fatal("an aborted form should render nothing") + } +} + +func TestFormsEmptyFormRunsWithoutPrompting(t *testing.T) { + var out bytes.Buffer + if err := forms.New(forms.NewPage("Nothing")).Accessible(true).IO(strings.NewReader("x\n"), &out).Run(); err != nil { + t.Fatal(err) + } + if out.Len() != 0 { + t.Fatalf("expected no output, got %q", out.String()) + } +} + +func TestFormsTitlesAndDescriptions(t *testing.T) { + builders := map[string]func() *forms.Field{ + "input": func() *forms.Field { var v string; return forms.NewInput(&v) }, + "text": func() *forms.Field { var v string; return forms.NewText(&v) }, + "select": func() *forms.Field { var v string; return forms.NewSelect(&v, forms.NewOption("one", "1")) }, + "confirm": func() *forms.Field { var v bool; return forms.NewConfirm(&v) }, + } + for name, build := range builders { + t.Run(name, func(t *testing.T) { + bare := formsView(formsStart(t, 80, 24, forms.NewPage("", build()))) + titled := formsView(formsStart(t, 80, 24, forms.NewPage("", build().Title("A Title")))) + described := formsView(formsStart(t, 80, 24, forms.NewPage("", build().Description("A Description")))) + formsContains(t, titled, "A Title") + formsContains(t, described, "A Description") + if lipgloss.Height(titled) != lipgloss.Height(bare)+1 || lipgloss.Height(described) != lipgloss.Height(bare)+1 { + t.Fatalf("title and description should each add one line: bare=%d titled=%d described=%d", + lipgloss.Height(bare), lipgloss.Height(titled), lipgloss.Height(described)) + } + }) + } +} + +func TestFormsRenderFitsWidth(t *testing.T) { + long := strings.Repeat("a fairly long description word ", 8) + for _, width := range []int{30, 50, 100} { + var a, b string + var c bool + f := formsStart(t, width, 40, forms.NewPage(long, + forms.NewInput(&a).Title(long).Description(long).Placeholder("p"), + forms.NewSelect(&b, forms.NewOption(long, "x"), forms.NewOption("y", "y")).Title(long), + forms.NewConfirm(&c).Title(long), + )) + lines := strings.Split(formsView(f), "\n") + for _, line := range lines { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: line is %d wide: %q", width, w, line) + } + } + // Writing the bottom-right cell scrolls some terminals. + if last := lines[len(lines)-1]; lipgloss.Width(last) >= width { + t.Fatalf("width %d: the last line reaches the final column: %q", width, last) + } + } +} + +func TestFormsResizeReflowsInput(t *testing.T) { + var v string + f := formsStart(t, 100, 24, forms.NewPage("", forms.NewInput(&v).Title(strings.Repeat("word ", 30)))) + wide := lipgloss.Height(formsView(f)) + f.Update(tea.WindowSizeMsg{Width: 30, Height: 24}) + if narrow := lipgloss.Height(formsView(f)); narrow <= wide { + t.Fatalf("narrowing should wrap the title: %d -> %d lines", wide, narrow) + } +} + +type formsPromptCase struct { + name string + input string + field func() (*forms.Field, func() any) + output []string + value any +} + +func TestFormsAccessiblePrompts(t *testing.T) { + str := func(initial string, build func(*string) *forms.Field) func() (*forms.Field, func() any) { + return func() (*forms.Field, func() any) { + v := initial + return build(&v), func() any { return v } + } + } + boolean := func(initial bool) func() (*forms.Field, func() any) { + return func() (*forms.Field, func() any) { + v := initial + return forms.NewConfirm(&v).Title("Choose"), func() any { return v } + } + } + abc := []forms.Option{forms.NewOption("a", "a"), forms.NewOption("b", "b"), forms.NewOption("c", "c"), forms.NewOption("d", "d")} + for _, tt := range []formsPromptCase{ + {name: "input", input: "Hello\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Title("Input:") }), output: []string{"Input:\n> "}, value: "Hello"}, + {name: "input without trailing newline", input: "Hello", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "Hello"}, + {name: "input with default", input: "\n", field: str("hi", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "hi"}, + {name: "input keeps surrounding spaces", input: " spaced secret \n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: " spaced secret "}, + {name: "input re-asks until valid", input: "\n \nok\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Validate(formsRequired) }), output: []string{"> required\n> required\n> "}, value: "ok"}, + {name: "input with description", input: "x\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Title("Name").Description("--name") }), output: []string{"Name\n--name\n> "}, value: "x"}, + {name: "password from a pipe", input: "s3cret\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v).Password() }), value: "s3cret"}, + {name: "crlf line ending", input: "value\r\n", field: str("", func(v *string) *forms.Field { return forms.NewInput(v) }), value: "value"}, + {name: "confirm", input: "Y\n", field: boolean(false), output: []string{"Choose\n[y/N]: "}, value: true}, + {name: "confirm with default", input: "\n", field: boolean(true), output: []string{"[Y/n]: "}, value: true}, + {name: "confirm with default choose", input: "n\n", field: boolean(true), output: []string{"Y/n"}, value: false}, + {name: "confirm long answers", input: "yes\n", field: boolean(false), value: true}, + {name: "confirm invalid", input: "maybe\nno\n", field: boolean(true), output: []string{"Answer y or n."}, value: false}, + {name: "select", input: "2\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:2]...).Title("Select:") }), output: []string{"Select:\n 1. a\n 2. b\n"}, value: "b"}, + {name: "select default value", input: "\n", field: str("c", func(v *string) *forms.Field { return forms.NewSelect(v, abc...) }), output: []string{"Choose 1-4 [3]: "}, value: "c"}, + {name: "select invalid", input: "0\nx\n5\n2\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:2]...) }), output: []string{"Enter a number from 1 to 2."}, value: "b"}, + {name: "select single option", input: "\n", field: str("", func(v *string) *forms.Field { return forms.NewSelect(v, abc[:1]...) }), output: []string{"Choose 1-1 [1]: "}, value: "a"}, + {name: "select end of input", input: "", field: str("b", func(v *string) *forms.Field { return forms.NewSelect(v, abc...) }), value: "b"}, + {name: "select multi-line label", input: "1\n", field: str("", func(v *string) *forms.Field { + return forms.NewSelect(v, forms.NewOption("Hi\nthere", "hi")) + }), output: []string{"Hi", "there"}, value: "hi"}, + {name: "text", input: "hello world\n", field: str("", func(v *string) *forms.Field { return forms.NewText(v).Title("Text:") }), output: []string{"Text:"}, value: "hello world"}, + {name: "text default value", input: "\n", field: str("test", func(v *string) *forms.Field { return forms.NewText(v) }), value: "test"}, + } { + t.Run(tt.name, func(t *testing.T) { + field, value := tt.field() + var out bytes.Buffer + if err := forms.New(forms.NewPage("", field)).Accessible(true).IO(strings.NewReader(tt.input), &out).Run(); err != nil { + t.Fatal(err) + } + for _, want := range tt.output { + formsContains(t, out.String(), want) + } + if got := value(); got != tt.value { + t.Fatalf("got %#v, want %#v", got, tt.value) + } + formsNotContains(t, out.String(), "s3cret") + }) + } +} + +func TestFormsAccessibleFormAnswersEveryField(t *testing.T) { + var token, user, region string + var ok bool + var out bytes.Buffer + err := forms.New( + forms.NewPage("Authentication", + forms.NewInput(&token).Title("Token").Password(), + forms.NewInput(&user).Title("User"), + ), + forms.NewPage("Settings", + forms.NewSelect(®ion, forms.NewOption("us", "us"), forms.NewOption("eu", "eu")).Title("Region"), + forms.NewConfirm(&ok).Title("Save?"), + ), + ).Accessible(true).IO(strings.NewReader("tok\nbob\n2\ny\n"), &out).Run() + if err != nil { + t.Fatal(err) + } + if token != "tok" || user != "bob" || region != "eu" || !ok { + t.Fatalf("got token=%q user=%q region=%q ok=%v\n%s", token, user, region, ok, out.String()) + } + formsContains(t, out.String(), "Token\n> ") + formsContains(t, out.String(), "Region\n") +} + +func TestFormsAccessibleEndOfInputStops(t *testing.T) { + a, b := "", "kept" + var out bytes.Buffer + err := forms.New(forms.NewPage("", + forms.NewInput(&a).Title("A"), + forms.NewInput(&b).Title("B").Validate(formsRequired), + )).Accessible(true).IO(strings.NewReader("first\n"), &out).Run() + if err != nil { + t.Fatal(err) + } + if a != "first" || b != "kept" { + t.Fatalf("got a=%q b=%q", a, b) + } +} + +type formsFailingReader struct{} + +func (formsFailingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + +func TestFormsAccessibleReadErrorIsReturned(t *testing.T) { + var v string + err := forms.New(forms.NewPage("", forms.NewInput(&v))).Accessible(true).IO(formsFailingReader{}, &bytes.Buffer{}).Run() + if err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("expected the read error, got %v", err) + } +} +{{- end}} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index a18fdd29a..e20f76f9b 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -60,13 +60,13 @@ trackedFiles: go.mod: last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:ea2c92b9f1f890b5f46c22e9ee0b176ef6a043a1 + last_write_checksum: sha1:e799fc569e3087788a37cf16a77c7442a321d9dd internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:d5ccc0970afac880e050719fe49c883432062904 internal/cli/configure.go: - last_write_checksum: sha1:c0eb651dc996a00cf7522256cb01446dc82adc18 + last_write_checksum: sha1:72b513337b8bdbb36773683424a76635e6b6a631 internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -250,11 +250,9 @@ trackedFiles: internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/forms/forms.go: - last_write_checksum: sha1:3b4568ad1cfd1be9130b52f6c259beda572153ec - internal/forms/forms_test.go: - last_write_checksum: sha1:b9956f9fb7944412760a85cf2f9018f58b1cbfac + last_write_checksum: sha1:d4ae507c7b6809f8368ba1fb22010ff2cc4ce652 internal/interactive/interactive.go: - last_write_checksum: sha1:d2bedc457439e63a680674e84aedb2aea85a68ad + last_write_checksum: sha1:612a1d394cfcda48c6384f8a258ac69da9db7541 internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index 4269a1abd..d6f7d7381 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -197,7 +197,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Title("Authentication Method"). Description("Choose which credentials to configure") - if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).Run(); err != nil { + if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -218,7 +218,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -251,7 +251,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -280,7 +280,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -309,7 +309,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -338,7 +338,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -372,7 +372,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -405,7 +405,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index 218f9b65d..248889533 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -278,7 +278,7 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { pages = append(pages, forms.NewPage("Preferences", preferenceFields...)) } - form := forms.New(pages...).Accessible(accessible) + form := forms.New(pages...).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("configure: %w", err) diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go index 3727d827b..eb9a30052 100644 --- a/zSDKs/sdk-cli/internal/forms/forms.go +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -64,8 +64,9 @@ type Field struct { yes *bool // confirm answer secret bool - options []Option - cursor int + options []Option + cursor int + halfPage int // rows moved by ctrl+d and ctrl+u, set from the visible window input textinput.Model area textarea.Model @@ -198,6 +199,10 @@ func (f *Field) update(msg tea.Msg) (cmd tea.Cmd, advance bool) { f.cursor = 0 case "end", "G": f.cursor = n - 1 + case "ctrl+d": + f.cursor = min(f.cursor+f.halfPage, n-1) + case "ctrl+u": + f.cursor = max(f.cursor-f.halfPage, 0) } *f.text = f.options[f.cursor].Value case kindConfirm: @@ -236,6 +241,8 @@ type Form struct { page int field int accessible bool + in io.Reader + out io.Writer width int height int finished bool @@ -244,7 +251,7 @@ type Form struct { // New returns a form over pages. Dumb terminals default to line prompts. func New(pages ...*Page) *Form { - f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb"} + f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb", in: os.Stdin, out: os.Stdout} f.resize(terminalWidth(), 0) return f } @@ -255,6 +262,13 @@ func (f *Form) Accessible(on bool) *Form { return f } +// IO sets where line prompts read answers and write questions; the +// interactive form always uses the terminal. +func (f *Form) IO(in io.Reader, out io.Writer) *Form { + f.in, f.out = in, out + return f +} + func terminalWidth() int { if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { return width @@ -269,8 +283,7 @@ func (f *Form) Run() error { return nil } if f.accessible { - f.prompt(os.Stdout, os.Stdin) - return nil + return f.prompt(f.out, f.in) } if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { if errors.Is(err, tea.ErrInterrupted) { @@ -307,37 +320,37 @@ func (f *Form) current() *Field { } func (f *Form) onLastField() bool { - return f.page == len(f.pages)-1 && f.field == len(f.pages[f.page].fields)-1 + _, _, ok := f.neighbour(1) + return !ok } -// step moves the focus by one field across page boundaries, skipping empty -// pages. It reports false when there is no field in that direction. -func (f *Form) step(delta int) bool { - page, field := f.page, f.field+delta +// neighbour finds the field one step away across page boundaries, skipping +// empty pages. ok is false when there is no field in that direction. +func (f *Form) neighbour(delta int) (page, field int, ok bool) { + page, field = f.page, f.field+delta for { switch { case field < 0: - page-- - if page < 0 { - return false + if page--; page < 0 { + return 0, 0, false } field = len(f.pages[page].fields) - 1 - continue case field >= len(f.pages[page].fields): - page++ - if page >= len(f.pages) { - return false + if page++; page >= len(f.pages) { + return 0, 0, false } field = 0 - continue + default: + return page, field, true } - f.page, f.field = page, field - return true } } // Init implements [tea.Model]. func (f *Form) Init() tea.Cmd { + if f.empty() { + return nil + } for f.field >= len(f.pages[f.page].fields) { f.page++ } @@ -387,11 +400,12 @@ func (f *Form) forward() tea.Cmd { } func (f *Form) move(delta int) tea.Cmd { - prev := f.current() - if !f.step(delta) { + page, field, ok := f.neighbour(delta) + if !ok { return nil } - prev.blur() + f.current().blur() + f.page, f.field = page, field return f.current().focus() } @@ -407,7 +421,8 @@ func (f *Form) render() string { page := f.pages[f.page] var b strings.Builder if page.title != "" { - b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(page.title) + "\n\n") + title := wrap(page.title, max(f.width, 10)) + b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title) + "\n\n") } for i, field := range page.fields { if i > 0 { @@ -426,10 +441,10 @@ func (f *Form) renderField(field *Field, focused bool) string { } lines := []string{} if field.title != "" { - lines = append(lines, title.Render(lipgloss.Wrap(field.title, inner, " "))) + lines = append(lines, title.Render(wrap(field.title, inner))) } if field.description != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(lipgloss.Wrap(field.description, inner, " "))) + lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) } switch field.kind { case kindInput: @@ -450,30 +465,53 @@ func (f *Form) renderField(field *Field, focused bool) string { Render(strings.Join(lines, "\n")) } -// renderOptions shows a window of options around the cursor that fits the -// terminal height. +// renderOptions shows the options around the cursor that fit the terminal +// height. Multi-line labels stay aligned under their first line. func (f *Form) renderOptions(field *Field, focused bool) string { - rows := len(field.options) - if f.height > 0 { - rows = min(rows, max(3, f.height-8)) - } - start := min(max(field.cursor-rows/2, 0), len(field.options)-rows) picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - var lines []string - for i := start; i < start+rows; i++ { + labelWidth := max(f.width-6, 4) + blocks := make([]string, len(field.options)) + for i, o := range field.options { + label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") if i == field.cursor { - lines = append(lines, picked.Render("> "+field.options[i].Label)) + blocks[i] = picked.Render("> " + label) } else { - lines = append(lines, " "+field.options[i].Label) + blocks[i] = " " + label + } + } + + budget := 0 + if f.height > 0 { + budget = max(3, f.height-8) + } + start, end := 0, len(blocks) + if budget > 0 && len(blocks) > 0 { + start, end = field.cursor, field.cursor+1 + used := lipgloss.Height(blocks[field.cursor]) + for grew := true; grew; { + grew = false + if end < len(blocks) && used+lipgloss.Height(blocks[end]) <= budget { + used += lipgloss.Height(blocks[end]) + end++ + grew = true + } + if start > 0 && used+lipgloss.Height(blocks[start-1]) <= budget { + start-- + used += lipgloss.Height(blocks[start]) + grew = true + } } } - if rows < len(field.options) { - lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(field.options)))) + field.halfPage = max((end-start)/2, 1) + + out := strings.Join(blocks[start:end], "\n") + if start > 0 || end < len(blocks) { + out += "\n" + lipgloss.NewStyle().Foreground(subtle).Render(fmt.Sprintf(" %d/%d", field.cursor+1, len(blocks))) } - return strings.Join(lines, "\n") + return out } func renderButtons(yes, focused bool) string { @@ -490,22 +528,26 @@ func renderButtons(yes, focused bool) string { // prompt asks every question as a plain line prompt. Blank answers keep the // current value; end of input leaves the remaining answers unchanged. -func (f *Form) prompt(w io.Writer, r io.Reader) { +func (f *Form) prompt(w io.Writer, r io.Reader) error { in := bufio.NewReader(r) for _, page := range f.pages { for _, field := range page.fields { - if !field.ask(w, r, in) { - fmt.Fprintln(w) - return - } + err := field.ask(w, r, in) fmt.Fprintln(w) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } } } + return nil } -// ask prompts for one field until the answer is valid. It reports false at +// ask prompts for one field until the answer is valid. It returns io.EOF at // end of input. -func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { +func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { if f.title != "" { fmt.Fprintln(w, f.title) } @@ -531,26 +573,27 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) bool { default: fmt.Fprint(w, "> ") } - line, ok := readAnswer(w, raw, in, f.secret) - if !ok { - return false + line, err := readAnswer(w, raw, in, f.secret) + if err != nil { + return err } if msg := f.answer(line); msg != "" { fmt.Fprintln(w, msg) continue } - return true + return nil } } // answer applies a typed line, returning a message when it must be re-asked. func (f *Field) answer(line string) string { - line = strings.TrimSpace(line) + blank := strings.TrimSpace(line) == "" switch f.kind { case kindSelect: - if line == "" { + if blank { return "" } + line = strings.TrimSpace(line) n, err := strconv.Atoi(line) if err != nil || n < 1 || n > len(f.options) { return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) @@ -558,7 +601,7 @@ func (f *Field) answer(line string) string { f.cursor = n - 1 *f.text = f.options[f.cursor].Value case kindConfirm: - switch strings.ToLower(line) { + switch strings.ToLower(strings.TrimSpace(line)) { case "": case "y", "yes": *f.yes = true @@ -569,7 +612,7 @@ func (f *Field) answer(line string) string { } default: value := *f.text - if line != "" { + if !blank { value = line } if f.validate != nil { @@ -583,15 +626,26 @@ func (f *Field) answer(line string) string { } // readAnswer reads one line, without echo for secrets typed at a terminal. -func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, bool) { +// It returns io.EOF only when no answer was read. +func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, error) { if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { b, err := term.ReadPassword(int(file.Fd())) fmt.Fprintln(w) - return string(b), err == nil + return string(b), err } line, err := in.ReadString('\n') - if err != nil && line == "" { - return "", false + if err != nil && (line == "" || !errors.Is(err, io.EOF)) { + return "", err } - return strings.TrimRight(line, "\r\n"), true + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +// wrap breaks s at spaces to fit width, without trailing spaces that some +// terminals scroll on in the last column. +func wrap(s string, width int) string { + lines := strings.Split(lipgloss.Wrap(s, width, " "), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.Join(lines, "\n") } diff --git a/zSDKs/sdk-cli/internal/forms/forms_test.go b/zSDKs/sdk-cli/internal/forms/forms_test.go deleted file mode 100644 index b397ebb39..000000000 --- a/zSDKs/sdk-cli/internal/forms/forms_test.go +++ /dev/null @@ -1,283 +0,0 @@ -// Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - -package forms - -import ( - "bytes" - "errors" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" -) - -func press(f *Form, keys ...string) tea.Cmd { - var last tea.Cmd - for _, k := range keys { - var msg tea.KeyPressMsg - switch k { - case "enter": - msg = tea.KeyPressMsg{Code: tea.KeyEnter} - case "tab": - msg = tea.KeyPressMsg{Code: tea.KeyTab} - case "shift+tab": - msg = tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} - case "up": - msg = tea.KeyPressMsg{Code: tea.KeyUp} - case "down": - msg = tea.KeyPressMsg{Code: tea.KeyDown} - case "left": - msg = tea.KeyPressMsg{Code: tea.KeyLeft} - case "backspace": - msg = tea.KeyPressMsg{Code: tea.KeyBackspace} - case "ctrl+c": - msg = tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} - case "ctrl+j": - msg = tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl} - default: - for _, r := range k { - _, last = f.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) - } - continue - } - _, last = f.Update(msg) - } - return last -} - -func isQuit(cmd tea.Cmd) bool { - if cmd == nil { - return false - } - _, ok := cmd().(tea.QuitMsg) - return ok -} - -func required(v string) error { - if strings.TrimSpace(v) == "" { - return errors.New("required") - } - return nil -} - -func TestFormStepsThroughPagesAndSubmits(t *testing.T) { - var token, user, format string - f := New( - NewPage("Authentication", - NewInput(&token).Title("Token").Password(), - NewInput(&user).Title("User").Placeholder("alice"), - ), - NewPage("Preferences", - NewSelect(&format, NewOption("Keep current", ""), NewOption("json", "json"), NewOption("yaml", "yaml")).Title("Format"), - ), - ) - f.Init() - - press(f, "secret", "tab", "bob", "shift+tab", "!", "enter", "enter") - if f.page != 1 { - t.Fatalf("expected the second page, got page %d", f.page) - } - if !strings.Contains(f.render(), "Preferences") || strings.Contains(f.render(), "Authentication") { - t.Fatalf("expected only the second page to render:\n%s", f.render()) - } - press(f, "down", "down", "down", "up") - if !isQuit(press(f, "enter")) { - t.Fatal("enter on the last field should submit") - } - if token != "secret!" || user != "bob" || format != "yaml" { - t.Fatalf("got token=%q user=%q format=%q", token, user, format) - } - if strings.Contains(f.render(), "secret") { - t.Fatal("password input must not render its value") - } -} - -func TestTabDoesNotSubmitFromLastField(t *testing.T) { - var v string - f := New(NewPage("", NewInput(&v))) - f.Init() - if press(f, "tab") != nil || f.finished { - t.Fatal("tab on the last field should do nothing") - } -} - -func TestValidationBlocksMovingForward(t *testing.T) { - var a, b string - f := New(NewPage("", NewInput(&a).Title("A").Validate(required), NewInput(&b).Title("B"))) - f.Init() - - press(f, "enter") - if f.field != 0 || !strings.Contains(f.render(), "required") { - t.Fatalf("expected to stay on A with an error, field=%d\n%s", f.field, f.render()) - } - press(f, "x") - if strings.Contains(f.render(), "required") { - t.Fatal("typing should clear the error") - } - press(f, "enter") - if f.field != 1 { - t.Fatalf("expected to move to B, field=%d", f.field) - } - press(f, "shift+tab") - if f.field != 0 { - t.Fatal("shift+tab should move back without validating") - } -} - -func TestTextInsertsNewlinesAndEnterMovesOn(t *testing.T) { - var body string - f := New(NewPage("", NewText(&body).Validate(required))) - f.Init() - press(f, `{"a":`, "ctrl+j", "1}") - if !isQuit(press(f, "enter")) { - t.Fatal("enter should submit the text field") - } - if body != "{\"a\":\n1}" { - t.Fatalf("got %q", body) - } -} - -func TestConfirmKeys(t *testing.T) { - var yes bool - f := New(NewPage("", NewConfirm(&yes).Title("Continue?"))) - f.Init() - press(f, "left") - if !yes { - t.Fatal("left should toggle") - } - if !isQuit(press(f, "n")) || yes { - t.Fatal("n should answer no and submit") - } - yes = false - f = New(NewPage("", NewConfirm(&yes))) - f.Init() - if !isQuit(press(f, "y")) || !yes { - t.Fatal("y should answer yes and submit") - } -} - -func TestSelectStartsAtCurrentValueAndWraps(t *testing.T) { - v := "b" - f := New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b"), NewOption("C", "c")))) - f.Init() - press(f, "down", "down") - if v != "a" { - t.Fatalf("expected the cursor to wrap to a, got %q", v) - } -} - -func TestSelectWindowFitsTerminalHeight(t *testing.T) { - var v string - options := make([]Option, 40) - for i := range options { - options[i] = NewOption("option-"+string(rune('A'+i%26)), string(rune('a'+i%26))) - } - f := New(NewPage("", NewSelect(&v, options...))) - f.Init() - f.Update(tea.WindowSizeMsg{Width: 80, Height: 12}) - if lines := strings.Count(f.render(), "\n") + 1; lines > 12 { - t.Fatalf("rendered %d lines for a 12-line terminal", lines) - } -} - -func TestCtrlCAborts(t *testing.T) { - var v string - f := New(NewPage("", NewInput(&v))) - f.Init() - if !isQuit(press(f, "ctrl+c")) || !f.aborted { - t.Fatal("ctrl+c should abort") - } -} - -func TestPrompts(t *testing.T) { - tests := []struct { - name string - input string - build func() (*Form, func() string) - want string - out []string - }{ - { - name: "input keeps current value on blank answer", - input: "\n", - build: func() (*Form, func() string) { - v := "kept" - return New(NewPage("", NewInput(&v).Title("Name"))), func() string { return v } - }, - want: "kept", - out: []string{"Name\n> "}, - }, - { - name: "input re-asks until valid", - input: "\n \nok\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewInput(&v).Validate(required))), func() string { return v } - }, - want: "ok", - out: []string{"required\n> required\n> "}, - }, - { - name: "secret read from a pipe is not echoed", - input: "s3cret\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewInput(&v).Title("Token").Password())), func() string { return v } - }, - want: "s3cret", - }, - { - name: "select by number", - input: "9\n2\n", - build: func() (*Form, func() string) { - var v string - return New(NewPage("", NewSelect(&v, NewOption("A", "a"), NewOption("B", "b")).Title("Pick"))), func() string { return v } - }, - want: "b", - out: []string{"Pick\n 1. A\n 2. B\nChoose 1-2 [1]: Enter a number from 1 to 2.\n"}, - }, - { - name: "confirm", - input: "maybe\nyes\n", - build: func() (*Form, func() string) { - var v bool - return New(NewPage("", NewConfirm(&v).Title("Sure?"))), func() string { - if v { - return "yes" - } - return "no" - } - }, - want: "yes", - out: []string{"[y/N]: Answer y or n.\n"}, - }, - { - name: "end of input stops asking", - input: "first\n", - build: func() (*Form, func() string) { - var a, b string - f := New(NewPage("", NewInput(&a).Title("A"), NewInput(&b).Title("B").Validate(required))) - return f, func() string { return a + "|" + b } - }, - want: "first|", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f, got := tt.build() - var out bytes.Buffer - f.prompt(&out, strings.NewReader(tt.input)) - if got() != tt.want { - t.Fatalf("got %q, want %q", got(), tt.want) - } - for _, s := range tt.out { - if !strings.Contains(out.String(), s) { - t.Fatalf("output %q does not contain %q", out.String(), s) - } - } - if strings.Contains(out.String(), "s3cret") { - t.Fatal("secret was echoed") - } - }) - } -} diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index 31a3c1c50..b136860cf 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -658,7 +658,7 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) for _, index := range required { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -667,14 +667,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(field) + answer, err := runPromptField(cmd, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -690,13 +690,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")))) + Description(strings.Join(names, ", ")))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(fields[index]) + answer, err := runPromptField(cmd, fields[index]) if err != nil { return nil, err } @@ -713,13 +713,13 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(field PromptField) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(field) + return runRepeatableFlagPrompt(cmd, field) } ask := func(f *forms.Field) error { - return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).Run() + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run() } switch field.Kind { @@ -799,7 +799,7 @@ func checkPromptValue(kind, value string) error { return nil } -func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { +func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -818,7 +818,7 @@ func runRepeatableFlagPrompt(field PromptField) (PromptAnswer, error) { return nil }) } - if err := forms.New(forms.NewPage("", input)).Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { From 46c3007f6963da496b0eb8863d76355aba8680b4 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 10:47:22 +0100 Subject: [PATCH 5/9] fix(cli): keep guided forms usable across terminal sizes Pages taller than the terminal now scroll: the form renders the window of lines that contains the focused field, moving it as little as possible, keeping the page title in view with the first field, and showing the input end of a field that is itself taller than the terminal. Previously the terminal showed only the bottom of the page, so on an 80x24 terminal the first fields of a long configure page were off screen while being edited. Selects size their option window from the space left after their own title and description, so the heading stays visible on short terminals. Widths below the previous 10-column floor no longer overflow, and confirm buttons drop their padding when the terminal is too narrow for it. Dumb terminals always get line prompts, even when a caller asks for the interactive form. Tests cover tall pages at several heights in both directions, the page title with the first field, a field taller than the terminal, selects with headings on short terminals, narrow widths, and TERM=dumb. --- .../auxiliary/internal/forms/forms.go.stmpl | 85 ++++++++++---- .../cli/tests/primary/forms_test.go.stmpl | 109 ++++++++++++++++++ zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/forms/forms.go | 85 ++++++++++---- 4 files changed, 240 insertions(+), 41 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl index 38c4cdf5e..6779a522d 100644 --- a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -161,7 +161,7 @@ func (f *Field) blur() { } func (f *Field) setWidth(width int) { - inner := max(width-4, 10) + inner := max(width-4, 1) switch f.kind { case kindInput: f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) @@ -244,23 +244,29 @@ type Form struct { out io.Writer width int height int + scroll int // first rendered line when the page is taller than the terminal finished bool aborted bool } -// New returns a form over pages. Dumb terminals default to line prompts. +// New returns a form over pages. func New(pages ...*Page) *Form { - f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb", in: os.Stdin, out: os.Stdout} + f := &Form{pages: pages, in: os.Stdin, out: os.Stdout} f.resize(terminalWidth(), 0) return f } -// Accessible switches between the interactive form and line prompts. +// Accessible switches between the interactive form and line prompts. Dumb +// terminals cannot redraw, so they always get line prompts. func (f *Form) Accessible(on bool) *Form { f.accessible = on return f } +func (f *Form) linePrompts() bool { + return f.accessible || os.Getenv("TERM") == "dumb" +} + // IO sets where line prompts read answers and write questions; the // interactive form always uses the terminal. func (f *Form) IO(in io.Reader, out io.Writer) *Form { @@ -281,7 +287,7 @@ func (f *Form) Run() error { if f.empty() { return nil } - if f.accessible { + if f.linePrompts() { return f.prompt(f.out, f.in) } if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { @@ -418,22 +424,52 @@ func (f *Form) View() tea.View { func (f *Form) render() string { page := f.pages[f.page] - var b strings.Builder + var lines []string if page.title != "" { - title := wrap(page.title, max(f.width, 10)) - b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title) + "\n\n") + title := wrap(page.title, max(f.width, 1)) + lines = append(lines, strings.Split(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title), "\n")...) + lines = append(lines, "") } + focusStart, focusEnd := 0, 0 for i, field := range page.fields { if i > 0 { - b.WriteString("\n\n") + lines = append(lines, "") + } + if i == f.field { + focusStart = len(lines) } - b.WriteString(f.renderField(field, i == f.field)) + lines = append(lines, strings.Split(f.renderField(field, i == f.field), "\n")...) + if i == f.field { + focusEnd = len(lines) + } + } + if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { + focusStart = 0 // keep the page title in view with the first field } - return b.String() + return strings.Join(f.scrollTo(lines, focusStart, focusEnd), "\n") +} + +// scrollTo returns the lines that fit the terminal, moving the window as +// little as possible to show lines[start:end]. A block taller than the +// terminal shows its end, where the input is. +func (f *Form) scrollTo(lines []string, start, end int) []string { + if f.height <= 0 || len(lines) <= f.height { + f.scroll = 0 + return lines + } + if end-start > f.height { + f.scroll = end - f.height + } else if start < f.scroll { + f.scroll = start + } else if end > f.scroll+f.height { + f.scroll = end - f.height + } + f.scroll = min(max(f.scroll, 0), len(lines)-f.height) + return lines[f.scroll : f.scroll+f.height] } func (f *Form) renderField(field *Field, focused bool) string { - inner := max(f.width-4, 10) + inner := max(f.width-4, 1) bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) if focused { bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) @@ -451,9 +487,14 @@ func (f *Form) renderField(field *Field, focused bool) string { case kindText: lines = append(lines, field.area.View()) case kindSelect: - lines = append(lines, f.renderOptions(field, focused)) + // Leave room for the field's own heading and the position line. + heading := 0 + for _, l := range lines { + heading += lipgloss.Height(l) + } + lines = append(lines, f.renderOptions(field, focused, f.height-heading-1)) case kindConfirm: - lines = append(lines, renderButtons(*field.yes, focused)) + lines = append(lines, renderButtons(*field.yes, focused, inner)) } if field.err != nil { lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) @@ -466,12 +507,12 @@ func (f *Form) renderField(field *Field, focused bool) string { // renderOptions shows the options around the cursor that fit the terminal // height. Multi-line labels stay aligned under their first line. -func (f *Form) renderOptions(field *Field, focused bool) string { +func (f *Form) renderOptions(field *Field, focused bool, rows int) string { picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - labelWidth := max(f.width-6, 4) + labelWidth := max(f.width-6, 1) blocks := make([]string, len(field.options)) for i, o := range field.options { label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") @@ -484,7 +525,7 @@ func (f *Form) renderOptions(field *Field, focused bool) string { budget := 0 if f.height > 0 { - budget = max(3, f.height-8) + budget = max(rows, 1) } start, end := 0, len(blocks) if budget > 0 && len(blocks) > 0 { @@ -513,12 +554,16 @@ func (f *Form) renderOptions(field *Field, focused bool) string { return out } -func renderButtons(yes, focused bool) string { - on := lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) +func renderButtons(yes, focused bool, width int) string { + pad := 2 + if width < 14 { + pad = 0 // "Yes No" without padding still fits very narrow terminals + } + on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) if focused { on = on.Background(accent) } - off := lipgloss.NewStyle().Padding(0, 2).Foreground(dimmed) + off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) if yes { return on.Render("Yes") + " " + off.Render("No") } diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl index 1f1ab6c1a..024152120 100644 --- a/templates/templates/cli/tests/primary/forms_test.go.stmpl +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -702,4 +702,113 @@ func TestFormsAccessibleReadErrorIsReturned(t *testing.T) { t.Fatalf("expected the read error, got %v", err) } } + +func formsTallPage(answers []string) *forms.Page { + fields := make([]*forms.Field, len(answers)) + for i := range answers { + fields[i] = forms.NewInput(&answers[i]).Title(fmt.Sprintf("Field %d", i)).Description("--flag") + } + return forms.NewPage("Tall page", fields...) +} + +func TestFormsTallPageKeepsFocusedFieldVisible(t *testing.T) { + for _, height := range []int{4, 6, 10, 15} { + t.Run(fmt.Sprint(height), func(t *testing.T) { + answers := make([]string, 8) + f := formsStart(t, 60, height, formsTallPage(answers)) + if height >= 6 { // room for the page title and the first field + formsContains(t, formsView(f), "Tall page") + } + for i := range answers { + formsType(f, fmt.Sprintf("m%d", i)) + v := formsView(f) + if got := lipgloss.Height(v); got > height { + t.Fatalf("field %d: rendered %d lines for height %d", i, got, height) + } + formsContains(t, v, fmt.Sprintf("> m%d", i)) + formsPress(f, "tab") + } + for i := len(answers) - 1; i >= 0; i-- { + formsContains(t, formsView(f), fmt.Sprintf("> m%d", i)) + formsPress(f, "shift+tab") + } + }) + } +} + +func TestFormsTallPageKeepsTitleWithFirstField(t *testing.T) { + answers := make([]string, 8) + f := formsStart(t, 60, 10, formsTallPage(answers)) + // Render after every key, as the terminal does. + for _, k := range []string{"tab", "tab", "tab", "shift+tab", "shift+tab", "shift+tab"} { + formsPress(f, k) + formsView(f) + } + v := formsView(f) + formsContains(t, v, "Tall page") + formsContains(t, v, "Field 0") +} + +func TestFormsSelectFitsWithItsHeading(t *testing.T) { + options := make([]forms.Option, 20) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("choice %d", i), fmt.Sprint(i)) + } + for _, height := range []int{6, 9, 14} { + var v string + f := formsStart(t, 40, height, forms.NewPage("", forms.NewSelect(&v, options...). + Title("A select title").Description("A description that wraps onto a second line here"))) + for range 22 { + view := formsView(f) + if lipgloss.Height(view) > height { + t.Fatalf("height %d: rendered %d lines", height, lipgloss.Height(view)) + } + formsContains(t, view, "A select title") + formsContains(t, view, "> choice "+v) + formsPress(f, "down") + } + } +} + +func TestFormsNarrowTerminalsDoNotOverflow(t *testing.T) { + for _, width := range []int{8, 12, 16, 20} { + var a, b string + var c bool + f := formsStart(t, width, 40, forms.NewPage("Settings", + forms.NewInput(&a).Title("Credential").Description("--credential").Placeholder("unset"), + forms.NewSelect(&b, forms.NewOption("Keep current", ""), forms.NewOption("json", "json")).Title("Format"), + forms.NewConfirm(&c).Title("Save?"), + )) + for _, line := range strings.Split(formsView(f), "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: %q is %d wide", width, line, w) + } + } + } +} + +func TestFormsDumbTerminalUsesLinePrompts(t *testing.T) { + t.Setenv("TERM", "dumb") + var v string + var out bytes.Buffer + if err := forms.New(forms.NewPage("", forms.NewInput(&v).Title("Name"))).Accessible(false).IO(strings.NewReader("ada\n"), &out).Run(); err != nil { + t.Fatal(err) + } + if v != "ada" { + t.Fatalf("got %q", v) + } + formsContains(t, out.String(), "Name\n> ") +} + +func TestFormsFieldTallerThanTerminalShowsItsInput(t *testing.T) { + var v string + f := formsStart(t, 20, 4, forms.NewPage("Page", + forms.NewInput(&v).Title("A title long enough to wrap over several narrow lines").Description("--a-long-flag-name"))) + formsType(f, "typed") + view := formsView(f) + if lipgloss.Height(view) > 4 { + t.Fatalf("rendered %d lines for height 4", lipgloss.Height(view)) + } + formsContains(t, view, "typed") +} {{- end}} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index e20f76f9b..ab31908e9 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -250,7 +250,7 @@ trackedFiles: internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/forms/forms.go: - last_write_checksum: sha1:d4ae507c7b6809f8368ba1fb22010ff2cc4ce652 + last_write_checksum: sha1:f68318cb87228968ac22eac98d37baa3e2fc49f8 internal/interactive/interactive.go: last_write_checksum: sha1:612a1d394cfcda48c6384f8a258ac69da9db7541 internal/interactive/policy.go: diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go index eb9a30052..749b4d731 100644 --- a/zSDKs/sdk-cli/internal/forms/forms.go +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -162,7 +162,7 @@ func (f *Field) blur() { } func (f *Field) setWidth(width int) { - inner := max(width-4, 10) + inner := max(width-4, 1) switch f.kind { case kindInput: f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) @@ -245,23 +245,29 @@ type Form struct { out io.Writer width int height int + scroll int // first rendered line when the page is taller than the terminal finished bool aborted bool } -// New returns a form over pages. Dumb terminals default to line prompts. +// New returns a form over pages. func New(pages ...*Page) *Form { - f := &Form{pages: pages, accessible: os.Getenv("TERM") == "dumb", in: os.Stdin, out: os.Stdout} + f := &Form{pages: pages, in: os.Stdin, out: os.Stdout} f.resize(terminalWidth(), 0) return f } -// Accessible switches between the interactive form and line prompts. +// Accessible switches between the interactive form and line prompts. Dumb +// terminals cannot redraw, so they always get line prompts. func (f *Form) Accessible(on bool) *Form { f.accessible = on return f } +func (f *Form) linePrompts() bool { + return f.accessible || os.Getenv("TERM") == "dumb" +} + // IO sets where line prompts read answers and write questions; the // interactive form always uses the terminal. func (f *Form) IO(in io.Reader, out io.Writer) *Form { @@ -282,7 +288,7 @@ func (f *Form) Run() error { if f.empty() { return nil } - if f.accessible { + if f.linePrompts() { return f.prompt(f.out, f.in) } if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { @@ -419,22 +425,52 @@ func (f *Form) View() tea.View { func (f *Form) render() string { page := f.pages[f.page] - var b strings.Builder + var lines []string if page.title != "" { - title := wrap(page.title, max(f.width, 10)) - b.WriteString(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title) + "\n\n") + title := wrap(page.title, max(f.width, 1)) + lines = append(lines, strings.Split(lipgloss.NewStyle().Foreground(accent).Bold(true).Render(title), "\n")...) + lines = append(lines, "") } + focusStart, focusEnd := 0, 0 for i, field := range page.fields { if i > 0 { - b.WriteString("\n\n") + lines = append(lines, "") + } + if i == f.field { + focusStart = len(lines) } - b.WriteString(f.renderField(field, i == f.field)) + lines = append(lines, strings.Split(f.renderField(field, i == f.field), "\n")...) + if i == f.field { + focusEnd = len(lines) + } + } + if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { + focusStart = 0 // keep the page title in view with the first field } - return b.String() + return strings.Join(f.scrollTo(lines, focusStart, focusEnd), "\n") +} + +// scrollTo returns the lines that fit the terminal, moving the window as +// little as possible to show lines[start:end]. A block taller than the +// terminal shows its end, where the input is. +func (f *Form) scrollTo(lines []string, start, end int) []string { + if f.height <= 0 || len(lines) <= f.height { + f.scroll = 0 + return lines + } + if end-start > f.height { + f.scroll = end - f.height + } else if start < f.scroll { + f.scroll = start + } else if end > f.scroll+f.height { + f.scroll = end - f.height + } + f.scroll = min(max(f.scroll, 0), len(lines)-f.height) + return lines[f.scroll : f.scroll+f.height] } func (f *Form) renderField(field *Field, focused bool) string { - inner := max(f.width-4, 10) + inner := max(f.width-4, 1) bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) if focused { bar, title = accent, lipgloss.NewStyle().Foreground(accent).Bold(true) @@ -452,9 +488,14 @@ func (f *Form) renderField(field *Field, focused bool) string { case kindText: lines = append(lines, field.area.View()) case kindSelect: - lines = append(lines, f.renderOptions(field, focused)) + // Leave room for the field's own heading and the position line. + heading := 0 + for _, l := range lines { + heading += lipgloss.Height(l) + } + lines = append(lines, f.renderOptions(field, focused, f.height-heading-1)) case kindConfirm: - lines = append(lines, renderButtons(*field.yes, focused)) + lines = append(lines, renderButtons(*field.yes, focused, inner)) } if field.err != nil { lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) @@ -467,12 +508,12 @@ func (f *Form) renderField(field *Field, focused bool) string { // renderOptions shows the options around the cursor that fit the terminal // height. Multi-line labels stay aligned under their first line. -func (f *Form) renderOptions(field *Field, focused bool) string { +func (f *Form) renderOptions(field *Field, focused bool, rows int) string { picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) } - labelWidth := max(f.width-6, 4) + labelWidth := max(f.width-6, 1) blocks := make([]string, len(field.options)) for i, o := range field.options { label := strings.ReplaceAll(wrap(o.Label, labelWidth), "\n", "\n ") @@ -485,7 +526,7 @@ func (f *Form) renderOptions(field *Field, focused bool) string { budget := 0 if f.height > 0 { - budget = max(3, f.height-8) + budget = max(rows, 1) } start, end := 0, len(blocks) if budget > 0 && len(blocks) > 0 { @@ -514,12 +555,16 @@ func (f *Form) renderOptions(field *Field, focused bool) string { return out } -func renderButtons(yes, focused bool) string { - on := lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) +func renderButtons(yes, focused bool, width int) string { + pad := 2 + if width < 14 { + pad = 0 // "Yes No" without padding still fits very narrow terminals + } + on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) if focused { on = on.Background(accent) } - off := lipgloss.NewStyle().Padding(0, 2).Foreground(dimmed) + off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) if yes { return on.Render("Yes") + " " + off.Render("No") } From 48ed7d2d98c2ce063c4523b950c6974021f60ca4 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 14:55:29 +0100 Subject: [PATCH 6/9] fix(cli): import forms in auth only when login prompts for credentials APIs without global security scheme groups generate an auth login that never builds a form, so the unconditional import failed to compile. --- templates/templates/cli/auth.go.stmpl | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index b9e2a2f7a..ca258d9e6 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -2,9 +2,8 @@ {{- addImport "github.com/spf13/cobra" -}} {{- addImport "internal/config" true -}} {{- addImport "internal/flagutil" true -}} -{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- end}} +{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- addImport "internal/forms" true -}}{{- end}} {{- addImport "fmt" -}} -{{- addImport "internal/forms" true -}} package cli {{genImports}} From af7401d27c1f13692b3361bd2f457c3ff5cf1ab1 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 15:04:36 +0100 Subject: [PATCH 7/9] fix(cli): keep guided forms from crashing on terminals under 8 columns The input width went negative below 8 columns, which panicked when the field rendered. Clamp it, and show compact Y/N confirm buttons when the full labels no longer fit. --- .../cli/auxiliary/internal/forms/forms.go.stmpl | 13 ++++++++----- .../templates/cli/tests/primary/forms_test.go.stmpl | 10 ++++++++-- zSDKs/sdk-cli/.speakeasy/gen.lock | 2 +- zSDKs/sdk-cli/internal/forms/forms.go | 13 ++++++++----- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl index 6779a522d..9583461ad 100644 --- a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -164,7 +164,7 @@ func (f *Field) setWidth(width int) { inner := max(width-4, 1) switch f.kind { case kindInput: - f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) + f.input.SetWidth(max(inner-lipgloss.Width(f.input.Prompt)-1, 1)) case kindText: f.area.SetWidth(inner) } @@ -555,9 +555,12 @@ func (f *Form) renderOptions(field *Field, focused bool, rows int) string { } func renderButtons(yes, focused bool, width int) string { - pad := 2 + pad, yesLabel, noLabel := 2, "Yes", "No" if width < 14 { - pad = 0 // "Yes No" without padding still fits very narrow terminals + pad = 0 + } + if width < 6 { + yesLabel, noLabel = "Y", "N" } on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) if focused { @@ -565,9 +568,9 @@ func renderButtons(yes, focused bool, width int) string { } off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) if yes { - return on.Render("Yes") + " " + off.Render("No") + return on.Render(yesLabel) + " " + off.Render(noLabel) } - return off.Render("Yes") + " " + on.Render("No") + return off.Render(yesLabel) + " " + on.Render(noLabel) } // prompt asks every question as a plain line prompt. Blank answers keep the diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl index 024152120..4f1f95c4f 100644 --- a/templates/templates/cli/tests/primary/forms_test.go.stmpl +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -771,7 +771,9 @@ func TestFormsSelectFitsWithItsHeading(t *testing.T) { } func TestFormsNarrowTerminalsDoNotOverflow(t *testing.T) { - for _, width := range []int{8, 12, 16, 20} { + // Below 6 columns the field border and input prompt alone overflow, so + // those widths only need to render without panicking. + for width := 1; width <= 20; width++ { var a, b string var c bool f := formsStart(t, width, 40, forms.NewPage("Settings", @@ -779,7 +781,11 @@ func TestFormsNarrowTerminalsDoNotOverflow(t *testing.T) { forms.NewSelect(&b, forms.NewOption("Keep current", ""), forms.NewOption("json", "json")).Title("Format"), forms.NewConfirm(&c).Title("Save?"), )) - for _, line := range strings.Split(formsView(f), "\n") { + view := formsView(f) + if width < 6 { + continue + } + for _, line := range strings.Split(view, "\n") { if w := lipgloss.Width(line); w > width { t.Fatalf("width %d: %q is %d wide", width, line, w) } diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index ab31908e9..fca3361a9 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -250,7 +250,7 @@ trackedFiles: internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/forms/forms.go: - last_write_checksum: sha1:f68318cb87228968ac22eac98d37baa3e2fc49f8 + last_write_checksum: sha1:84f77e414e9387a1d46cd3dbc7dcb6f52d85da44 internal/interactive/interactive.go: last_write_checksum: sha1:612a1d394cfcda48c6384f8a258ac69da9db7541 internal/interactive/policy.go: diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go index 749b4d731..1239fcd1a 100644 --- a/zSDKs/sdk-cli/internal/forms/forms.go +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -165,7 +165,7 @@ func (f *Field) setWidth(width int) { inner := max(width-4, 1) switch f.kind { case kindInput: - f.input.SetWidth(inner - lipgloss.Width(f.input.Prompt) - 1) + f.input.SetWidth(max(inner-lipgloss.Width(f.input.Prompt)-1, 1)) case kindText: f.area.SetWidth(inner) } @@ -556,9 +556,12 @@ func (f *Form) renderOptions(field *Field, focused bool, rows int) string { } func renderButtons(yes, focused bool, width int) string { - pad := 2 + pad, yesLabel, noLabel := 2, "Yes", "No" if width < 14 { - pad = 0 // "Yes No" without padding still fits very narrow terminals + pad = 0 + } + if width < 6 { + yesLabel, noLabel = "Y", "N" } on := lipgloss.NewStyle().Padding(0, pad).Foreground(lipgloss.Color("#FFFFFF")).Background(subtle) if focused { @@ -566,9 +569,9 @@ func renderButtons(yes, focused bool, width int) string { } off := lipgloss.NewStyle().Padding(0, pad).Foreground(dimmed) if yes { - return on.Render("Yes") + " " + off.Render("No") + return on.Render(yesLabel) + " " + off.Render(noLabel) } - return off.Render("Yes") + " " + on.Render("No") + return off.Render(yesLabel) + " " + on.Render(noLabel) } // prompt asks every question as a plain line prompt. Blank answers keep the From 2cf15595ec35d407740da6a84e5b953e01564a66 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 18:25:19 +0100 Subject: [PATCH 8/9] fix(cli): fit form errors and editors to short terminals and share prompt input - Wrap validation errors to the field width and budget their rows when sizing select windows and the multi-line editor, which now shrinks to fit short terminals instead of hiding its first line. - Keep the focused input in view when a field and its error are taller than the terminal. - Validate select answers in line prompts, including a kept default, and re-ask when the validator rejects them. - Add forms.Reader so consecutive forms over one input share buffered answers; auth login and command prompts use one reader per command. - Read interactive keys from the configured input when it is not stdin, so ctrl+c on scripted input returns ErrUserAborted. --- .../auxiliary/internal/forms/forms.go.stmpl | 160 ++++++++++---- .../internal/interactive/interactive.go.stmpl | 22 +- templates/templates/cli/includes/security.ts | 8 +- .../auth_login_additional_test.go.stmpl | 14 ++ .../cli/tests/primary/forms_test.go.stmpl | 209 +++++++++++++++++- zSDKs/sdk-cli/.speakeasy/gen.lock | 6 +- zSDKs/sdk-cli/internal/cli/auth.go | 17 +- zSDKs/sdk-cli/internal/forms/forms.go | 160 ++++++++++---- .../internal/interactive/interactive.go | 22 +- 9 files changed, 480 insertions(+), 138 deletions(-) diff --git a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl index 9583461ad..012ba8fd9 100644 --- a/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/forms/forms.go.stmpl @@ -78,6 +78,9 @@ func NewInput(value *string) *Field { return &Field{kind: kindInput, text: value, input: in} } +// textRows is the editor height when the terminal has room for it. +const textRows = 6 + // NewText asks for multi-line text such as JSON, starting from *value. // alt+enter or ctrl+j inserts a newline; enter moves on. func NewText(value *string) *Field { @@ -85,6 +88,7 @@ func NewText(value *string) *Field { area.ShowLineNumbers = false area.Prompt = "" area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetHeight(textRows) area.SetValue(*value) return &Field{kind: kindText, text: value, area: area} } @@ -135,10 +139,17 @@ func (f *Field) Validate(validate func(string) error) *Field { } func (f *Field) check() error { - if f.validate == nil || f.text == nil { + if f.text == nil { + return nil + } + return f.checkValue(*f.text) +} + +func (f *Field) checkValue(value string) error { + if f.validate == nil { return nil } - return f.validate(*f.text) + return f.validate(value) } func (f *Field) focus() tea.Cmd { @@ -267,8 +278,9 @@ func (f *Form) linePrompts() bool { return f.accessible || os.Getenv("TERM") == "dumb" } -// IO sets where line prompts read answers and write questions; the -// interactive form always uses the terminal. +// IO sets where line prompts read answers and write questions. The +// interactive form also reads keys from in unless it is standard input, and +// always draws on the terminal. func (f *Form) IO(in io.Reader, out io.Writer) *Form { f.in, f.out = in, out return f @@ -290,7 +302,11 @@ func (f *Form) Run() error { if f.linePrompts() { return f.prompt(f.out, f.in) } - if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { + opts := []tea.ProgramOption{tea.WithOutput(os.Stderr)} + if in := keyInput(f.in); in != os.Stdin { + opts = append(opts, tea.WithInput(in)) + } + if _, err := tea.NewProgram(f, opts...).Run(); err != nil { if errors.Is(err, tea.ErrInterrupted) { return ErrUserAborted } @@ -302,6 +318,17 @@ func (f *Form) Run() error { return nil } +// keyInput returns where the interactive form reads keys: the file behind a +// shared Reader so a terminal can be put in raw mode, otherwise in itself. +// Standard input is left to bubbletea, which falls back to the terminal when +// stdin is piped. +func keyInput(in io.Reader) io.Reader { + if shared, ok := in.(*Reader); ok && shared.file != nil { + return shared.file + } + return in +} + func (f *Form) empty() bool { for _, p := range f.pages { if len(p.fields) > 0 { @@ -438,9 +465,15 @@ func (f *Form) render() string { if i == f.field { focusStart = len(lines) } - lines = append(lines, strings.Split(f.renderField(field, i == f.field), "\n")...) + block, errRows := f.renderField(field, i == f.field) + lines = append(lines, strings.Split(block, "\n")...) if i == f.field { focusEnd = len(lines) + if f.height > 0 && focusEnd-focusStart > f.height && errRows > 1 { + // Too tall for the terminal: keep the input in view with the + // first line of its error under it. + focusEnd -= errRows - 1 + } } } if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { @@ -468,7 +501,9 @@ func (f *Form) scrollTo(lines []string, start, end int) []string { return lines[f.scroll : f.scroll+f.height] } -func (f *Form) renderField(field *Field, focused bool) string { +// renderField draws a field inside its border and reports how many of the +// rows at its end are the validation error. +func (f *Form) renderField(field *Field, focused bool) (string, int) { inner := max(f.width-4, 1) bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) if focused { @@ -481,33 +516,43 @@ func (f *Form) renderField(field *Field, focused bool) string { if field.description != "" { lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) } + var errLines []string + if field.err != nil { + errLines = append(errLines, lipgloss.NewStyle().Foreground(errColor).Render(wrap("! "+field.err.Error(), inner))) + } + // Rows left for the editor or options once the heading and error fit. + avail := f.height - rows(lines) - rows(errLines) switch field.kind { case kindInput: lines = append(lines, field.input.View()) case kindText: + if f.height > 0 { + field.area.SetHeight(min(max(avail, 1), textRows)) + } lines = append(lines, field.area.View()) case kindSelect: - // Leave room for the field's own heading and the position line. - heading := 0 - for _, l := range lines { - heading += lipgloss.Height(l) - } - lines = append(lines, f.renderOptions(field, focused, f.height-heading-1)) + lines = append(lines, f.renderOptions(field, focused, avail-1)) // minus the position line case kindConfirm: lines = append(lines, renderButtons(*field.yes, focused, inner)) } - if field.err != nil { - lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) - } + lines = append(lines, errLines...) return lipgloss.NewStyle(). BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). PaddingLeft(1). - Render(strings.Join(lines, "\n")) + Render(strings.Join(lines, "\n")), rows(errLines) +} + +func rows(blocks []string) int { + n := 0 + for _, b := range blocks { + n += lipgloss.Height(b) + } + return n } // renderOptions shows the options around the cursor that fit the terminal // height. Multi-line labels stay aligned under their first line. -func (f *Form) renderOptions(field *Field, focused bool, rows int) string { +func (f *Form) renderOptions(field *Field, focused bool, height int) string { picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) @@ -525,7 +570,7 @@ func (f *Form) renderOptions(field *Field, focused bool, rows int) string { budget := 0 if f.height > 0 { - budget = max(rows, 1) + budget = max(height, 1) } start, end := 0, len(blocks) if budget > 0 && len(blocks) > 0 { @@ -573,13 +618,33 @@ func renderButtons(yes, focused bool, width int) string { return off.Render(yesLabel) + " " + on.Render(noLabel) } +// Reader buffers answers for line prompts. Pass the same Reader to every form +// a command runs over one input so that answers read ahead by one form are +// left for the next. +type Reader struct { + buf *bufio.Reader + file *os.File // the terminal secrets are read from without echo +} + +// NewReader wraps r for sharing between forms. A Reader is returned as is. +func NewReader(r io.Reader) *Reader { + if shared, ok := r.(*Reader); ok { + return shared + } + file, _ := r.(*os.File) + return &Reader{buf: bufio.NewReader(r), file: file} +} + +// Read implements [io.Reader]. +func (r *Reader) Read(p []byte) (int, error) { return r.buf.Read(p) } + // prompt asks every question as a plain line prompt. Blank answers keep the // current value; end of input leaves the remaining answers unchanged. func (f *Form) prompt(w io.Writer, r io.Reader) error { - in := bufio.NewReader(r) + in := NewReader(r) for _, page := range f.pages { for _, field := range page.fields { - err := field.ask(w, r, in) + err := field.ask(w, in) fmt.Fprintln(w) if errors.Is(err, io.EOF) { return nil @@ -594,7 +659,7 @@ func (f *Form) prompt(w io.Writer, r io.Reader) error { // ask prompts for one field until the answer is valid. It returns io.EOF at // end of input. -func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { +func (f *Field) ask(w io.Writer, in *Reader) error { if f.title != "" { fmt.Fprintln(w, f.title) } @@ -620,7 +685,7 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { default: fmt.Fprint(w, "> ") } - line, err := readAnswer(w, raw, in, f.secret) + line, err := readAnswer(w, in, f.secret) if err != nil { return err } @@ -635,19 +700,7 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { // answer applies a typed line, returning a message when it must be re-asked. func (f *Field) answer(line string) string { blank := strings.TrimSpace(line) == "" - switch f.kind { - case kindSelect: - if blank { - return "" - } - line = strings.TrimSpace(line) - n, err := strconv.Atoi(line) - if err != nil || n < 1 || n > len(f.options) { - return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) - } - f.cursor = n - 1 - *f.text = f.options[f.cursor].Value - case kindConfirm: + if f.kind == kindConfirm { switch strings.ToLower(strings.TrimSpace(line)) { case "": case "y", "yes": @@ -657,30 +710,41 @@ func (f *Field) answer(line string) string { default: return "Answer y or n." } - default: - value := *f.text - if !blank { - value = line + return "" + } + value, pick := *f.text, f.cursor + if f.kind == kindSelect { + if len(f.options) == 0 { + return "" } - if f.validate != nil { - if err := f.validate(value); err != nil { - return err.Error() + if !blank { + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) } + pick = n - 1 } - *f.text = value + value = f.options[pick].Value + } else if !blank { + value = line + } + if err := f.checkValue(value); err != nil { + return err.Error() } + f.cursor = pick + *f.text = value return "" } // readAnswer reads one line, without echo for secrets typed at a terminal. // It returns io.EOF only when no answer was read. -func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, error) { - if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { - b, err := term.ReadPassword(int(file.Fd())) +func readAnswer(w io.Writer, in *Reader, secret bool) (string, error) { + if in.file != nil && secret && term.IsTerminal(int(in.file.Fd())) { + b, err := term.ReadPassword(int(in.file.Fd())) fmt.Fprintln(w) return string(b), err } - line, err := in.ReadString('\n') + line, err := in.buf.ReadString('\n') if err != nil && (line == "" || !errors.Is(err, io.EOF)) { return "", err } diff --git a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl index 0811d8b2c..dcf2abcb9 100644 --- a/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/interactive/interactive.go.stmpl @@ -656,8 +656,10 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) + // Every prompt reads from one reader so piped answers carry over. + in := forms.NewReader(cmd.InOrStdin()) for _, index := range required { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -666,14 +668,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(cmd, field) + answer, err := runPromptField(cmd, in, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -689,13 +691,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()) + Description(strings.Join(names, ", ")))).IO(in, cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -712,13 +714,13 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(cmd, field) + return runRepeatableFlagPrompt(cmd, in, field) } ask := func(f *forms.Field) error { - return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run() + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(in, cmd.ErrOrStderr()).Run() } switch field.Kind { @@ -798,7 +800,7 @@ func checkPromptValue(kind, value string) error { return nil } -func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { +func runRepeatableFlagPrompt(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -817,7 +819,7 @@ func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswe return nil }) } - if err := forms.New(forms.NewPage("", input)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(in, cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 00f642e67..fcc491da3 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2551,8 +2551,10 @@ function templateAuthLoginBody(): string { lines.push(``); lines.push(...genSchemeStore(group.fields)); } else { - // Multiple OR alternatives — show scheme selector first + // Multiple OR alternatives — show scheme selector first. Both forms read + // line prompts from one shared reader so piped answers carry over. lines.push(`accessible := formMode == interactive.FormAccessible`); + lines.push(`promptInput := forms.NewReader(cmd.InOrStdin())`); lines.push(``); lines.push(`var selectedScheme string`); lines.push(`schemeSelect := forms.NewSelect(&selectedScheme,`); @@ -2568,7 +2570,7 @@ function templateAuthLoginBody(): string { lines.push(` Description("Choose which credentials to configure")`); lines.push(``); lines.push( - `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()).Run(); err != nil {`, + `if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()).Run(); err != nil {`, ); lines.push(` return fmt.Errorf("auth login: %w", err)`); lines.push(`}`); @@ -2589,7 +2591,7 @@ function templateAuthLoginBody(): string { lines.push(` }`); lines.push(``); lines.push( - ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout())`, + ` form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout())`, ); lines.push(``); lines.push(` if err := form.Run(); err != nil {`); diff --git a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl index 316ab2a4b..173f81d66 100644 --- a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl @@ -187,4 +187,18 @@ func TestAuthLogoutClearsCredentials(t *testing.T) { }) } } + +func TestAuthLoginLinePromptsReadMethodAndCredentialsFromOneInput(t *testing.T) { + installKeyring(t, false, nil) + home := t.TempDir() + + h := NewCLITestHarness(t).WithEnv("HOME", home) + stdin := strings.NewReader("3\nsekr1t-pass\nalice\n") + require.NoError(t, h.RunPolicyDefaultWithInput([]string{"auth", "login", "--interactive"}, stdin), "stderr: %s", h.GetStderr()) + assert.Contains(t, h.GetStdout(), "Authentication Method") + + cfg := readAuthConfig(t, home) + assert.Contains(t, cfg, "username: alice") + assert.Contains(t, cfg, "password: sekr1t-pass") +} {{- end}} diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl index 4f1f95c4f..c12206ea4 100644 --- a/templates/templates/cli/tests/primary/forms_test.go.stmpl +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -6,6 +6,7 @@ import ( "errors" "fmt" "regexp" + "slices" "strings" "testing" @@ -372,14 +373,14 @@ func TestFormsSelectPageNavigation(t *testing.T) { visible := func() []string { var shown []string for _, n := range names { - if regexp.MustCompile(`(?m)^┃ [> ] ` + n + `$`).MatchString(formsView(f)) { + if regexp.MustCompile(`(?m)^┃ [> ] ` + n + ` *$`).MatchString(formsView(f)) { shown = append(shown, n) } } return shown } - if len(visible()) >= len(names) { - t.Fatal("expected a window smaller than the option list") + if n := len(visible()); n == 0 || n >= len(names) { + t.Fatalf("expected a window smaller than the option list, got %d options", n) } formsContains(t, formsView(f), "> Qux") @@ -392,15 +393,19 @@ func TestFormsSelectPageNavigation(t *testing.T) { formsPress(f, "home") formsContains(t, formsView(f), "> Qux") + index := func() int { return slices.Index(names, v) } + half := len(visible()) / 2 formsPress(f, "ctrl+d") - if v == "Qux" || v == "Ping" { - t.Fatalf("ctrl+d should move half a window, got %q", v) + if got := index(); got != half { + t.Fatalf("ctrl+d from the top should move %d rows, got option %d", half, got) } formsContains(t, formsView(f), "> "+v) - formsPress(f, "end", "ctrl+u") - if v == "Qux" || v == "Ping" { - t.Fatalf("ctrl+u should move half a window, got %q", v) + formsPress(f, "end") + half = len(visible()) / 2 + formsPress(f, "ctrl+u") + if got := index(); got != len(names)-1-half { + t.Fatalf("ctrl+u from the bottom should move %d rows, got option %d", half, got) } formsContains(t, formsView(f), "> "+v) for range 30 { @@ -817,4 +822,192 @@ func TestFormsFieldTallerThanTerminalShowsItsInput(t *testing.T) { } formsContains(t, view, "typed") } + +func formsIntegerOnly(v string) error { + if _, err := fmt.Sscanf(v, "%d", new(int)); err != nil { + return errors.New("must be an integer") + } + return nil +} + +func formsAssertFits(t *testing.T, view string, width, height int) { + t.Helper() + if got := lipgloss.Height(view); got > height { + t.Fatalf("rendered %d lines for height %d:\n%s", got, height, view) + } + for _, line := range strings.Split(view, "\n") { + if w := lipgloss.Width(line); w > width { + t.Fatalf("width %d: %q is %d wide", width, line, w) + } + } +} + +// formsUnwrapped joins a view's text with borders and spaces removed, so +// wrapped words can be matched whole. +func formsUnwrapped(view string) string { + return strings.NewReplacer("┃", "", " ", "", "\n", "").Replace(view) +} + +func TestFormsValidationErrorWrapsToWidth(t *testing.T) { + for _, width := range []int{8, 12, 20} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + var a string + f := formsStart(t, width, 12, forms.NewPage("", forms.NewInput(&a).Title("Count").Validate(formsIntegerOnly))) + formsType(f, "x") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, width, 12) + formsContains(t, formsUnwrapped(view), "!mustbeaninteger") + formsContains(t, view, "> x") + + b := "bad" + f = formsStart(t, width, 12, forms.NewPage("", forms.NewSelect(&b, forms.NewOption("bad", "bad"), forms.NewOption("ok", "ok")). + Title("Pick").Validate(formsIntegerOnly))) + formsPress(f, "enter") + view = formsView(f) + formsAssertFits(t, view, width, 12) + formsContains(t, formsUnwrapped(view), "!mustbeaninteger") + formsContains(t, formsUnwrapped(view), ">bad") + }) + } +} + +func TestFormsSelectKeepsHeadingWhenValidationFails(t *testing.T) { + options := make([]forms.Option, 20) + for i := range options { + options[i] = forms.NewOption(fmt.Sprintf("choice %d", i), fmt.Sprint(i)) + } + var v string + f := formsStart(t, 80, 10, forms.NewPage("", forms.NewSelect(&v, options...).Title("Choose"). + Validate(func(string) error { return errors.New("nope") }))) + formsPress(f, "down", "down") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 80, 10) + formsContains(t, view, "Choose") + formsContains(t, view, "> choice 2") + formsContains(t, view, "nope") +} + +func TestFormsTextFitsShortTerminals(t *testing.T) { + for _, height := range []int{4, 5, 6, 12, 30} { + t.Run(fmt.Sprint(height), func(t *testing.T) { + var value string + f := formsStart(t, 20, height, forms.NewPage("", forms.NewText(&value).Title("JSON"))) + f.Update(tea.PasteMsg{Content: "VISIBLE"}) + view := formsView(f) + formsAssertFits(t, view, 20, height) + formsContains(t, view, "VISIBLE") + formsContains(t, view, "JSON") + + formsType(f, "typed") + formsContains(t, formsView(f), "VISIBLEtyped") + }) + } +} + +func TestFormsTextResizesAroundValidationError(t *testing.T) { + var value string + f := formsStart(t, 20, 30, forms.NewPage("", forms.NewText(&value).Title("JSON").Validate(formsRequired))) + f.Update(tea.WindowSizeMsg{Width: 20, Height: 4}) + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 20, 4) + formsContains(t, view, "required") + formsType(f, "now") + view = formsView(f) + formsAssertFits(t, view, 20, 4) + formsContains(t, view, "now") + f.Update(tea.WindowSizeMsg{Width: 20, Height: 30}) + if got := lipgloss.Height(formsView(f)); got < 6 { + t.Fatalf("a tall terminal should give the editor its default rows, got %d lines", got) + } +} + +func TestFormsInputStaysVisibleWhenErrorOverflows(t *testing.T) { + for _, height := range []int{2, 3, 4} { + var a string + f := formsStart(t, 8, height, forms.NewPage("Page", forms.NewInput(&a).Title("Count").Validate(formsIntegerOnly))) + formsType(f, "x") + formsPress(f, "enter") + view := formsView(f) + formsAssertFits(t, view, 8, height) + formsContains(t, view, "> x") + formsContains(t, view, "!") + } +} + +func TestFormsTextFollowsCursorOnShortTerminals(t *testing.T) { + var value string + f := formsStart(t, 30, 4, forms.NewPage("", forms.NewText(&value).Title("JSON"))) + for i := range 8 { + formsType(f, fmt.Sprintf("line%d", i)) + formsPress(f, "ctrl+j") + formsView(f) + } + formsType(f, "last") + view := formsView(f) + formsAssertFits(t, view, 30, 4) + formsContains(t, view, "last") + formsContains(t, view, "line7") +} + +func TestFormsAccessibleSelectValidates(t *testing.T) { + options := []forms.Option{forms.NewOption("Rejected", "bad"), forms.NewOption("Accepted", "good")} + for _, tt := range []struct{ name, initial, input string }{ + {name: "rejected pick", input: "1\n2\n"}, + {name: "rejected default", initial: "bad", input: "\n2\n"}, + } { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + v := tt.initial + var out bytes.Buffer + err := forms.New(forms.NewPage("", forms.NewSelect(&v, options...).Validate(func(v string) error { + calls++ + if v == "bad" { + return errors.New("not that one") + } + return nil + }))).Accessible(true).IO(strings.NewReader(tt.input), &out).Run() + if err != nil { + t.Fatal(err) + } + if v != "good" || calls != 2 { + t.Fatalf("got %q after %d validations\n%s", v, calls, out.String()) + } + formsContains(t, out.String(), "not that one\nChoose 1-2") + }) + } +} + +func TestFormsSharedReaderSpansForms(t *testing.T) { + var method, user, secret string + var out bytes.Buffer + in := forms.NewReader(strings.NewReader("1\nalice\nsecret\n")) + err := forms.New(forms.NewPage("", forms.NewSelect(&method, forms.NewOption("Basic", "basic"), forms.NewOption("Token", "token")))). + Accessible(true).IO(in, &out).Run() + if err != nil { + t.Fatal(err) + } + err = forms.New(forms.NewPage("", forms.NewInput(&user).Title("User"), forms.NewInput(&secret).Title("Secret").Password())). + Accessible(true).IO(in, &out).Run() + if err != nil { + t.Fatal(err) + } + if method != "basic" || user != "alice" || secret != "secret" { + t.Fatalf("got method=%q user=%q secret=%q\n%s", method, user, secret, out.String()) + } + if forms.NewReader(in) != in { + t.Fatal("wrapping a shared reader again should return it unchanged") + } +} + +func TestFormsRunReturnsErrUserAbortedOnCtrlC(t *testing.T) { + t.Setenv("TERM", "xterm-256color") // a dumb terminal would get line prompts + var v string + err := forms.New(forms.NewPage("", forms.NewInput(&v).Title("Foo"))).IO(strings.NewReader("\x03"), &bytes.Buffer{}).Run() + if !errors.Is(err, forms.ErrUserAborted) { + t.Fatalf("got %v, want ErrUserAborted", err) + } +} {{- end}} diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index fca3361a9..99472b15c 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -60,7 +60,7 @@ trackedFiles: go.mod: last_write_checksum: sha1:75fd840a41af9780eddf7431ab9168a0c2c2facd internal/cli/auth.go: - last_write_checksum: sha1:e799fc569e3087788a37cf16a77c7442a321d9dd + last_write_checksum: sha1:a64e4aae57deeb18045a8601f4aff422b39931c0 internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: @@ -250,9 +250,9 @@ trackedFiles: internal/flagutil/preset.go: last_write_checksum: sha1:a6cf90b7dd09d11ffbd438fc3aba76806de5752a internal/forms/forms.go: - last_write_checksum: sha1:84f77e414e9387a1d46cd3dbc7dcb6f52d85da44 + last_write_checksum: sha1:c21352632fd50812eda8d9ac9159208e26c15cfa internal/interactive/interactive.go: - last_write_checksum: sha1:612a1d394cfcda48c6384f8a258ac69da9db7541 + last_write_checksum: sha1:1a5cba48c32805e2756093f15aa476eae3b87b6c internal/interactive/policy.go: last_write_checksum: sha1:3cf3eec72867230610aa5e7c308fddcaf916b81b internal/interactive/policy_test.go: diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index d6f7d7381..a70f4341b 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -183,6 +183,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { } else { accessible := formMode == interactive.FormAccessible + promptInput := forms.NewReader(cmd.InOrStdin()) var selectedScheme string schemeSelect := forms.NewSelect(&selectedScheme, @@ -197,7 +198,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Title("Authentication Method"). Description("Choose which credentials to configure") - if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()).Run(); err != nil { + if err := forms.New(forms.NewPage("", schemeSelect)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()).Run(); err != nil { return fmt.Errorf("auth login: %w", err) } @@ -218,7 +219,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("password", cfg.Security.Password))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -251,7 +252,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -280,7 +281,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("oauth2", cfg.Security.Oauth2))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -309,7 +310,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("secret", cfg.Security.Secret))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -338,7 +339,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("mobile-auth", cfg.Security.MobileAuth))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -372,7 +373,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(cmp.Or(cfg.Security.TokenURL, "/clientcredentials/token")), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) @@ -405,7 +406,7 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error { Placeholder(maskSecret(config.GetStoredSecret("my-api-key", cfg.Security.MyApiKey))), } - form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(cmd.InOrStdin(), cmd.OutOrStdout()) + form := forms.New(forms.NewPage("", fields...)).Accessible(accessible).IO(promptInput, cmd.OutOrStdout()) if err := form.Run(); err != nil { return fmt.Errorf("auth login: %w", err) diff --git a/zSDKs/sdk-cli/internal/forms/forms.go b/zSDKs/sdk-cli/internal/forms/forms.go index 1239fcd1a..41efde310 100644 --- a/zSDKs/sdk-cli/internal/forms/forms.go +++ b/zSDKs/sdk-cli/internal/forms/forms.go @@ -79,6 +79,9 @@ func NewInput(value *string) *Field { return &Field{kind: kindInput, text: value, input: in} } +// textRows is the editor height when the terminal has room for it. +const textRows = 6 + // NewText asks for multi-line text such as JSON, starting from *value. // alt+enter or ctrl+j inserts a newline; enter moves on. func NewText(value *string) *Field { @@ -86,6 +89,7 @@ func NewText(value *string) *Field { area.ShowLineNumbers = false area.Prompt = "" area.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j") + area.SetHeight(textRows) area.SetValue(*value) return &Field{kind: kindText, text: value, area: area} } @@ -136,10 +140,17 @@ func (f *Field) Validate(validate func(string) error) *Field { } func (f *Field) check() error { - if f.validate == nil || f.text == nil { + if f.text == nil { + return nil + } + return f.checkValue(*f.text) +} + +func (f *Field) checkValue(value string) error { + if f.validate == nil { return nil } - return f.validate(*f.text) + return f.validate(value) } func (f *Field) focus() tea.Cmd { @@ -268,8 +279,9 @@ func (f *Form) linePrompts() bool { return f.accessible || os.Getenv("TERM") == "dumb" } -// IO sets where line prompts read answers and write questions; the -// interactive form always uses the terminal. +// IO sets where line prompts read answers and write questions. The +// interactive form also reads keys from in unless it is standard input, and +// always draws on the terminal. func (f *Form) IO(in io.Reader, out io.Writer) *Form { f.in, f.out = in, out return f @@ -291,7 +303,11 @@ func (f *Form) Run() error { if f.linePrompts() { return f.prompt(f.out, f.in) } - if _, err := tea.NewProgram(f, tea.WithOutput(os.Stderr)).Run(); err != nil { + opts := []tea.ProgramOption{tea.WithOutput(os.Stderr)} + if in := keyInput(f.in); in != os.Stdin { + opts = append(opts, tea.WithInput(in)) + } + if _, err := tea.NewProgram(f, opts...).Run(); err != nil { if errors.Is(err, tea.ErrInterrupted) { return ErrUserAborted } @@ -303,6 +319,17 @@ func (f *Form) Run() error { return nil } +// keyInput returns where the interactive form reads keys: the file behind a +// shared Reader so a terminal can be put in raw mode, otherwise in itself. +// Standard input is left to bubbletea, which falls back to the terminal when +// stdin is piped. +func keyInput(in io.Reader) io.Reader { + if shared, ok := in.(*Reader); ok && shared.file != nil { + return shared.file + } + return in +} + func (f *Form) empty() bool { for _, p := range f.pages { if len(p.fields) > 0 { @@ -439,9 +466,15 @@ func (f *Form) render() string { if i == f.field { focusStart = len(lines) } - lines = append(lines, strings.Split(f.renderField(field, i == f.field), "\n")...) + block, errRows := f.renderField(field, i == f.field) + lines = append(lines, strings.Split(block, "\n")...) if i == f.field { focusEnd = len(lines) + if f.height > 0 && focusEnd-focusStart > f.height && errRows > 1 { + // Too tall for the terminal: keep the input in view with the + // first line of its error under it. + focusEnd -= errRows - 1 + } } } if f.field == 0 && (f.height <= 0 || focusEnd <= f.height) { @@ -469,7 +502,9 @@ func (f *Form) scrollTo(lines []string, start, end int) []string { return lines[f.scroll : f.scroll+f.height] } -func (f *Form) renderField(field *Field, focused bool) string { +// renderField draws a field inside its border and reports how many of the +// rows at its end are the validation error. +func (f *Form) renderField(field *Field, focused bool) (string, int) { inner := max(f.width-4, 1) bar, title := subtle, lipgloss.NewStyle().Foreground(dimmed) if focused { @@ -482,33 +517,43 @@ func (f *Form) renderField(field *Field, focused bool) string { if field.description != "" { lines = append(lines, lipgloss.NewStyle().Foreground(subtle).Italic(true).Render(wrap(field.description, inner))) } + var errLines []string + if field.err != nil { + errLines = append(errLines, lipgloss.NewStyle().Foreground(errColor).Render(wrap("! "+field.err.Error(), inner))) + } + // Rows left for the editor or options once the heading and error fit. + avail := f.height - rows(lines) - rows(errLines) switch field.kind { case kindInput: lines = append(lines, field.input.View()) case kindText: + if f.height > 0 { + field.area.SetHeight(min(max(avail, 1), textRows)) + } lines = append(lines, field.area.View()) case kindSelect: - // Leave room for the field's own heading and the position line. - heading := 0 - for _, l := range lines { - heading += lipgloss.Height(l) - } - lines = append(lines, f.renderOptions(field, focused, f.height-heading-1)) + lines = append(lines, f.renderOptions(field, focused, avail-1)) // minus the position line case kindConfirm: lines = append(lines, renderButtons(*field.yes, focused, inner)) } - if field.err != nil { - lines = append(lines, lipgloss.NewStyle().Foreground(errColor).Render("! "+field.err.Error())) - } + lines = append(lines, errLines...) return lipgloss.NewStyle(). BorderStyle(lipgloss.ThickBorder()).BorderLeft(true).BorderForeground(bar). PaddingLeft(1). - Render(strings.Join(lines, "\n")) + Render(strings.Join(lines, "\n")), rows(errLines) +} + +func rows(blocks []string) int { + n := 0 + for _, b := range blocks { + n += lipgloss.Height(b) + } + return n } // renderOptions shows the options around the cursor that fit the terminal // height. Multi-line labels stay aligned under their first line. -func (f *Form) renderOptions(field *Field, focused bool, rows int) string { +func (f *Form) renderOptions(field *Field, focused bool, height int) string { picked := lipgloss.NewStyle().Foreground(dimmed) if focused { picked = lipgloss.NewStyle().Foreground(accent).Bold(true) @@ -526,7 +571,7 @@ func (f *Form) renderOptions(field *Field, focused bool, rows int) string { budget := 0 if f.height > 0 { - budget = max(rows, 1) + budget = max(height, 1) } start, end := 0, len(blocks) if budget > 0 && len(blocks) > 0 { @@ -574,13 +619,33 @@ func renderButtons(yes, focused bool, width int) string { return off.Render(yesLabel) + " " + on.Render(noLabel) } +// Reader buffers answers for line prompts. Pass the same Reader to every form +// a command runs over one input so that answers read ahead by one form are +// left for the next. +type Reader struct { + buf *bufio.Reader + file *os.File // the terminal secrets are read from without echo +} + +// NewReader wraps r for sharing between forms. A Reader is returned as is. +func NewReader(r io.Reader) *Reader { + if shared, ok := r.(*Reader); ok { + return shared + } + file, _ := r.(*os.File) + return &Reader{buf: bufio.NewReader(r), file: file} +} + +// Read implements [io.Reader]. +func (r *Reader) Read(p []byte) (int, error) { return r.buf.Read(p) } + // prompt asks every question as a plain line prompt. Blank answers keep the // current value; end of input leaves the remaining answers unchanged. func (f *Form) prompt(w io.Writer, r io.Reader) error { - in := bufio.NewReader(r) + in := NewReader(r) for _, page := range f.pages { for _, field := range page.fields { - err := field.ask(w, r, in) + err := field.ask(w, in) fmt.Fprintln(w) if errors.Is(err, io.EOF) { return nil @@ -595,7 +660,7 @@ func (f *Form) prompt(w io.Writer, r io.Reader) error { // ask prompts for one field until the answer is valid. It returns io.EOF at // end of input. -func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { +func (f *Field) ask(w io.Writer, in *Reader) error { if f.title != "" { fmt.Fprintln(w, f.title) } @@ -621,7 +686,7 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { default: fmt.Fprint(w, "> ") } - line, err := readAnswer(w, raw, in, f.secret) + line, err := readAnswer(w, in, f.secret) if err != nil { return err } @@ -636,19 +701,7 @@ func (f *Field) ask(w io.Writer, raw io.Reader, in *bufio.Reader) error { // answer applies a typed line, returning a message when it must be re-asked. func (f *Field) answer(line string) string { blank := strings.TrimSpace(line) == "" - switch f.kind { - case kindSelect: - if blank { - return "" - } - line = strings.TrimSpace(line) - n, err := strconv.Atoi(line) - if err != nil || n < 1 || n > len(f.options) { - return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) - } - f.cursor = n - 1 - *f.text = f.options[f.cursor].Value - case kindConfirm: + if f.kind == kindConfirm { switch strings.ToLower(strings.TrimSpace(line)) { case "": case "y", "yes": @@ -658,30 +711,41 @@ func (f *Field) answer(line string) string { default: return "Answer y or n." } - default: - value := *f.text - if !blank { - value = line + return "" + } + value, pick := *f.text, f.cursor + if f.kind == kindSelect { + if len(f.options) == 0 { + return "" } - if f.validate != nil { - if err := f.validate(value); err != nil { - return err.Error() + if !blank { + n, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || n < 1 || n > len(f.options) { + return fmt.Sprintf("Enter a number from 1 to %d.", len(f.options)) } + pick = n - 1 } - *f.text = value + value = f.options[pick].Value + } else if !blank { + value = line + } + if err := f.checkValue(value); err != nil { + return err.Error() } + f.cursor = pick + *f.text = value return "" } // readAnswer reads one line, without echo for secrets typed at a terminal. // It returns io.EOF only when no answer was read. -func readAnswer(w io.Writer, raw io.Reader, in *bufio.Reader, secret bool) (string, error) { - if file, ok := raw.(*os.File); ok && secret && term.IsTerminal(int(file.Fd())) { - b, err := term.ReadPassword(int(file.Fd())) +func readAnswer(w io.Writer, in *Reader, secret bool) (string, error) { + if in.file != nil && secret && term.IsTerminal(int(in.file.Fd())) { + b, err := term.ReadPassword(int(in.file.Fd())) fmt.Fprintln(w) return string(b), err } - line, err := in.ReadString('\n') + line, err := in.buf.ReadString('\n') if err != nil && (line == "" || !errors.Is(err, io.EOF)) { return "", err } diff --git a/zSDKs/sdk-cli/internal/interactive/interactive.go b/zSDKs/sdk-cli/internal/interactive/interactive.go index b136860cf..95e9a4f95 100644 --- a/zSDKs/sdk-cli/internal/interactive/interactive.go +++ b/zSDKs/sdk-cli/internal/interactive/interactive.go @@ -657,8 +657,10 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } } printCommandHeader(cmd, len(required), len(optionalArgs)+len(direct)+len(optionalFlags)) + // Every prompt reads from one reader so piped answers carry over. + in := forms.NewReader(cmd.InOrStdin()) for _, index := range required { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -667,14 +669,14 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn for _, index := range optionalArgs { field := fields[index] field.Summary = optionalArgDescription(field.Summary) - answer, err := runPromptField(cmd, field) + answer, err := runPromptField(cmd, in, field) if err != nil { return nil, err } answers[index] = answer } for _, index := range direct { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -690,13 +692,13 @@ func (formPrompter) Prompt(cmd *cobra.Command, fields []PromptField) ([]PromptAn } form := forms.New(forms.NewPage("", forms.NewConfirm(&fillOptional). Title(fmt.Sprintf("Fill in %d optional field(s)?", len(optionalFlags))). - Description(strings.Join(names, ", ")))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()) + Description(strings.Join(names, ", ")))).IO(in, cmd.ErrOrStderr()) if err := form.Run(); err != nil { return nil, err } if fillOptional { for _, index := range optionalFlags { - answer, err := runPromptField(cmd, fields[index]) + answer, err := runPromptField(cmd, in, fields[index]) if err != nil { return nil, err } @@ -713,13 +715,13 @@ func optionalArgDescription(summary string) string { return summary + " · optional, leave empty to skip" } -func runPromptField(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { +func runPromptField(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { if field.Repeatable && field.target == promptTargetFlag { - return runRepeatableFlagPrompt(cmd, field) + return runRepeatableFlagPrompt(cmd, in, field) } ask := func(f *forms.Field) error { - return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run() + return forms.New(forms.NewPage("", f.Title(field.Name).Description(field.Summary))).IO(in, cmd.ErrOrStderr()).Run() } switch field.Kind { @@ -799,7 +801,7 @@ func checkPromptValue(kind, value string) error { return nil } -func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswer, error) { +func runRepeatableFlagPrompt(cmd *cobra.Command, in *forms.Reader, field PromptField) (PromptAnswer, error) { values := make([]string, 0, 1) for { value := "" @@ -818,7 +820,7 @@ func runRepeatableFlagPrompt(cmd *cobra.Command, field PromptField) (PromptAnswe return nil }) } - if err := forms.New(forms.NewPage("", input)).IO(cmd.InOrStdin(), cmd.ErrOrStderr()).Run(); err != nil { + if err := forms.New(forms.NewPage("", input)).IO(in, cmd.ErrOrStderr()).Run(); err != nil { return PromptAnswer{}, err } if strings.TrimSpace(value) == "" { From 39385660b3f099c1339f608d73ae14def9f68a68 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 8 Oct 2026 18:30:27 +0100 Subject: [PATCH 9/9] fix(cli): tighten guided form tests Reject trailing characters in the integer validator used by the form tests, and assert which authentication method the piped login picks. --- .../cli/tests/primary/auth_login_additional_test.go.stmpl | 2 ++ templates/templates/cli/tests/primary/forms_test.go.stmpl | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl index 173f81d66..d4c0b5794 100644 --- a/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl +++ b/templates/templates/cli/tests/primary/auth_login_additional_test.go.stmpl @@ -193,9 +193,11 @@ func TestAuthLoginLinePromptsReadMethodAndCredentialsFromOneInput(t *testing.T) home := t.TempDir() h := NewCLITestHarness(t).WithEnv("HOME", home) + // Option 3 is basic auth, which prompts for the password before the username. stdin := strings.NewReader("3\nsekr1t-pass\nalice\n") require.NoError(t, h.RunPolicyDefaultWithInput([]string{"auth", "login", "--interactive"}, stdin), "stderr: %s", h.GetStderr()) assert.Contains(t, h.GetStdout(), "Authentication Method") + assert.Contains(t, h.GetStdout(), "3. HTTP Basic (Username + Password)") cfg := readAuthConfig(t, home) assert.Contains(t, cfg, "username: alice") diff --git a/templates/templates/cli/tests/primary/forms_test.go.stmpl b/templates/templates/cli/tests/primary/forms_test.go.stmpl index c12206ea4..dcdac62b6 100644 --- a/templates/templates/cli/tests/primary/forms_test.go.stmpl +++ b/templates/templates/cli/tests/primary/forms_test.go.stmpl @@ -7,6 +7,7 @@ import ( "fmt" "regexp" "slices" + "strconv" "strings" "testing" @@ -824,7 +825,7 @@ func TestFormsFieldTallerThanTerminalShowsItsInput(t *testing.T) { } func formsIntegerOnly(v string) error { - if _, err := fmt.Sscanf(v, "%d", new(int)); err != nil { + if _, err := strconv.Atoi(strings.TrimSpace(v)); err != nil { return errors.New("must be an integer") } return nil